【Cocos Creator 3.8】InvestigatePosの実装:アタッチするだけで「指定位置まで移動して周囲をキョロキョロ見回す」挙動を実現する汎用スクリプト

このコンポーネントは、敵キャラや警備ロボットなどに「不審な位置まで移動して、周囲を見回す」挙動を簡単に付与するための汎用スクリプトです。
任意のノード(例: 敵キャラのルートノード)にアタッチし、インスペクタで「調査対象位置」や「移動速度」「見回す回数」などを設定するだけで動作します。
他のカスタムスクリプトやシングルトンに一切依存せず、単体で完結するように設計しています。


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

1. 機能要件の整理

InvestigatePos コンポーネントは、以下の流れで動作します。

  1. 開始位置から「調査位置」まで移動する
    • インスペクタで指定した targetPosition(Vec3)まで、一定速度で移動。
    • 到達判定は「距離がしきい値以下になったら到達」とする。
  2. 到達後、周囲をキョロキョロ見回す
    • 左右に首(ノードのY軸回転)を振る。
    • 「左右に何回振るか」「何度まで振るか」「一往復にかける時間」をインスペクタから調整可能にする。
    • 見回し中は移動しない。
  3. 見回し完了後の挙動
    • オプションで「元の位置に戻る」か「その場で終了」かを選択可能にする。
    • 終了後は何もしない(ステートは Idle)。

また、外部依存を無くすため、「プレイヤーを見失った位置」は他スクリプトから受け取るのではなく、インスペクタで直接指定するベクトルとして扱います。
「ゲーム内で動的に座標を渡したい」場合でも、このコンポーネント単体で使えるよう、公開メソッドでターゲット位置をセットし直せるように設計します。

2. ステートマシン設計(内部状態)

コンポーネント内部では、簡易ステートマシンで挙動を管理します。

  • Idle:何もしていない状態(初期状態/調査完了後)
  • MovingToTarget:調査位置へ移動中
  • Investigating:その場でキョロキョロ見回し中
  • Returning:元の位置へ戻り中(オプション)

このステートはコンポーネント内の列挙型として定義し、update() 内で状態に応じた処理を行います。

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

InvestigatePos でインスペクタから調整可能なプロパティと、その役割は以下の通りです。

  • autoStart: boolean
    • ツールチップ: 「true の場合、start() 時に自動で調査行動を開始します。」
    • デフォルト: true
    • ゲーム開始と同時に動かしたい場合は ON、スクリプトから明示的に開始したい場合は OFF。
  • targetPosition: Vec3
    • ツールチップ: 「調査対象のワールド座標。(例: プレイヤーを見失った位置)」
    • デフォルト: new Vec3(0, 0, 0)
    • この座標までノードを移動させます。
    • ワールド座標として扱うため、インスペクタ上では「世界座標で指定」するイメージで設定します。
  • moveSpeed: number
    • ツールチップ: 「調査位置までの移動速度(単位: ユニット/秒)。」
    • デフォルト: 3.0
    • 値を大きくすると速く移動します。
  • arrivalThreshold: number
    • ツールチップ: 「目標位置到達とみなす距離(小さいほど厳密)。」
    • デフォルト: 0.05
    • 移動先にピッタリ重ならなくてもよいので、少し余裕を持たせるための距離です。
  • lookAngle: number
    • ツールチップ: 「左右に首を振る最大角度(度数法)。例: 45 なら -45°〜+45° の範囲。」
    • デフォルト: 45
    • キョロキョロの「振り幅」を調整できます。
  • lookDurationPerSide: number
    • ツールチップ: 「片側へ首を振るのにかける時間(秒)。」
    • デフォルト: 0.5
    • 小さいほど素早くキョロキョロします。
  • lookCycles: number
    • ツールチップ: 「左右の往復回数。1 なら 左→右→中央 で1往復。」
    • デフォルト: 2
    • 周囲をどれだけ念入りに見回すかを決めます。
  • returnToStart: boolean
    • ツールチップ: 「調査完了後に元の位置へ戻るかどうか。」
    • デフォルト: true
    • 警備ルートに戻したい場合は ON、その場に留めたい場合は OFF。
  • debugLog: boolean
    • ツールチップ: 「状態遷移などのデバッグログをコンソールに出力します。」
    • デフォルト: false
    • 挙動確認時に有効にすると、ステートの変化が分かりやすくなります。

4. 公開メソッド(他スクリプトから任意で呼べるAPI)

外部スクリプトに依存はしませんが、「もし他のスクリプトから使いたい場合」に備えて、以下のメソッドを公開します。

  • startInvestigation(targetWorldPos?: Vec3)
    • 引数を省略した場合:インスペクタの targetPosition を使って調査開始。
    • 引数に Vec3 を渡した場合:その座標を新しいターゲットとして調査開始し、targetPosition も更新。
  • isBusy(): boolean
    • 現在、移動や見回し動作中かどうかを返します。

TypeScriptコードの実装

以下が InvestigatePos コンポーネントの完全な実装コードです。


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

enum InvestigateState {
    Idle = 0,
    MovingToTarget = 1,
    Investigating = 2,
    Returning = 3,
}

@ccclass('InvestigatePos')
export class InvestigatePos extends Component {

    @property({
        tooltip: 'true の場合、start() 時に自動で調査行動を開始します。',
    })
    public autoStart: boolean = true;

    @property({
        tooltip: '調査対象のワールド座標。(例: プレイヤーを見失った位置)',
    })
    public targetPosition: Vec3 = new Vec3(0, 0, 0);

    @property({
        tooltip: '調査位置までの移動速度(単位: ユニット/秒)。',
        min: 0.01,
    })
    public moveSpeed: number = 3.0;

    @property({
        tooltip: '目標位置到達とみなす距離(小さいほど厳密)。',
        min: 0.001,
    })
    public arrivalThreshold: number = 0.05;

    @property({
        tooltip: '左右に首を振る最大角度(度数法)。例: 45 なら -45°〜+45° の範囲。',
        min: 0,
        max: 180,
    })
    public lookAngle: number = 45;

    @property({
        tooltip: '片側へ首を振るのにかける時間(秒)。',
        min: 0.05,
    })
    public lookDurationPerSide: number = 0.5;

    @property({
        tooltip: '左右の往復回数。1 なら 左→右→中央 で1往復。',
        min: 0,
        step: 1,
    })
    public lookCycles: number = 2;

    @property({
        tooltip: '調査完了後に元の位置へ戻るかどうか。',
    })
    public returnToStart: boolean = true;

    @property({
        tooltip: '状態遷移などのデバッグログをコンソールに出力します。',
    })
    public debugLog: boolean = false;

    // 内部状態
    private _state: InvestigateState = InvestigateState.Idle;
    private _startWorldPos: Vec3 = new Vec3();
    private _startWorldRot: Quat = new Quat();

    private _moveStartPos: Vec3 = new Vec3();
    private _moveTargetPos: Vec3 = new Vec3();
    private _moveTotalDistance: number = 0;
    private _moveElapsed: number = 0;

    // 見回し用
    private _investElapsed: number = 0;
    private _totalInvestDuration: number = 0;
    private _originalRot: Quat = new Quat();
    private _leftRot: Quat = new Quat();
    private _rightRot: Quat = new Quat();

    onLoad() {
        // 初期位置と回転を保存(ワールド座標系)
        const node = this.node;
        node.getWorldPosition(this._startWorldPos);
        node.getWorldRotation(this._startWorldRot);

        if (this.moveSpeed <= 0) {
            console.error('[InvestigatePos] moveSpeed は 0 より大きい値を設定してください。現在値:', this.moveSpeed);
        }
        if (this.lookDurationPerSide <= 0) {
            console.error('[InvestigatePos] lookDurationPerSide は 0 より大きい値を設定してください。現在値:', this.lookDurationPerSide);
        }
    }

    start() {
        if (this.autoStart) {
            this.startInvestigation();
        }
    }

    update(deltaTime: number) {
        switch (this._state) {
            case InvestigateState.MovingToTarget:
                this._updateMoving(deltaTime);
                break;
            case InvestigateState.Investigating:
                this._updateInvestigating(deltaTime);
                break;
            case InvestigateState.Returning:
                this._updateReturning(deltaTime);
                break;
            case InvestigateState.Idle:
            default:
                // 何もしない
                break;
        }
    }

    /**
     * 調査行動を開始する。
     * @param targetWorldPos 省略時はインスペクタの targetPosition を使用。
     */
    public startInvestigation(targetWorldPos?: Vec3) {
        if (targetWorldPos) {
            this.targetPosition.set(targetWorldPos);
        }

        if (this.moveSpeed <= 0) {
            console.error('[InvestigatePos] 調査開始に失敗: moveSpeed が 0 以下です。');
            return;
        }

        // 現在位置からターゲットへ向かう移動をセットアップ
        this.node.getWorldPosition(this._moveStartPos);
        this._moveTargetPos.set(this.targetPosition);
        this._moveTotalDistance = Vec3.distance(this._moveStartPos, this._moveTargetPos);
        this._moveElapsed = 0;

        if (this._moveTotalDistance <= this.arrivalThreshold) {
            // ほぼ同じ位置なら、すぐに見回しへ
            this._enterInvestigating();
        } else {
            this._changeState(InvestigateState.MovingToTarget);
        }
    }

    /**
     * 今このコンポーネントが移動・調査動作中かどうか。
     */
    public isBusy(): boolean {
        return this._state !== InvestigateState.Idle;
    }

    // ---------------- 内部処理 ----------------

    private _changeState(newState: InvestigateState) {
        if (this._state === newState) {
            return;
        }
        if (this.debugLog) {
            console.log(`[InvestigatePos] State: ${InvestigateState[this._state]} -> ${InvestigateState[newState]}`);
        }
        this._state = newState;

        if (newState === InvestigateState.Investigating) {
            this._setupInvestigating();
        } else if (newState === InvestigateState.Returning) {
            this._setupReturning();
        }
    }

    private _updateMoving(deltaTime: number) {
        this._moveElapsed += deltaTime;
        const node = this.node;

        const currentPos = new Vec3();
        node.getWorldPosition(currentPos);

        const dir = new Vec3();
        Vec3.subtract(dir, this._moveTargetPos, currentPos);
        const distance = dir.length();

        if (distance <= this.arrivalThreshold) {
            // 到達
            node.setWorldPosition(this._moveTargetPos);
            this._enterInvestigating();
            return;
        }

        dir.normalize();
        const moveStep = this.moveSpeed * deltaTime;
        if (moveStep >= distance) {
            // オーバーシュート防止
            node.setWorldPosition(this._moveTargetPos);
            this._enterInvestigating();
        } else {
            Vec3.scaleAndAdd(currentPos, currentPos, dir, moveStep);
            node.setWorldPosition(currentPos);
        }
    }

    private _enterInvestigating() {
        if (this.lookCycles <= 0 || this.lookAngle <= 0 || this.lookDurationPerSide <= 0) {
            // 見回し設定が無効なら、そのまま終了 or 戻る
            if (this.returnToStart) {
                this._changeState(InvestigateState.Returning);
            } else {
                this._changeState(InvestigateState.Idle);
            }
            return;
        }
        this._changeState(InvestigateState.Investigating);
    }

    private _setupInvestigating() {
        const node = this.node;

        // 現在の回転を基準として、左右の回転を作る
        node.getWorldRotation(this._originalRot);

        // Y軸回転の左右角度(度)をクォータニオンに変換
        const leftEuler = new Vec3(0, -this.lookAngle, 0);
        const rightEuler = new Vec3(0, this.lookAngle, 0);

        const leftDelta = new Quat();
        const rightDelta = new Quat();
        Quat.fromEuler(leftDelta, leftEuler.x, leftEuler.y, leftEuler.z);
        Quat.fromEuler(rightDelta, rightEuler.x, rightEuler.y, rightEuler.z);

        // originalRot に対する相対回転として左右を作成
        Quat.multiply(this._leftRot, this._originalRot, leftDelta);
        Quat.multiply(this._rightRot, this._originalRot, rightDelta);

        // 総時間 = (左 → 右 → 左 → … の往復) * 回数 + 最後に中央へ戻る時間
        // 1往復: 左(0)→右(1) で 2 * lookDurationPerSide
        // 最後に中央へ戻る: lookDurationPerSide
        this._totalInvestDuration = (this.lookCycles * 2 * this.lookDurationPerSide) + this.lookDurationPerSide;
        this._investElapsed = 0;
    }

    private _updateInvestigating(deltaTime: number) {
        if (this._totalInvestDuration <= 0) {
            // 何らかの異常
            if (this.returnToStart) {
                this._changeState(InvestigateState.Returning);
            } else {
                this._changeState(InvestigateState.Idle);
            }
            return;
        }

        this._investElapsed += deltaTime;
        const t = math.clamp01(this._investElapsed / this._totalInvestDuration);

        // t を 0〜1 として、以下の区間で動作を分ける
        // 区間長: segment = lookDurationPerSide / totalInvestDuration
        const segment = this.lookDurationPerSide / this._totalInvestDuration;

        // 各区間:
        // 0: [0, segment)             -> 中央 -> 左
        // 1: [segment, 2segment)      -> 左   -> 右
        // 2: [2segment, 3segment)     -> 右   -> 左
        // ...
        // 最後: 中央へ戻る区間
        //
        // 実際には、最初から左に振るのではなく、「最初は中央から左へ」など
        // 好みで変えられるが、ここではシンプルに以下のようにする:
        //
        // - まず 左 へ
        // - 左 <-> 右 を lookCycles 回 往復
        // - 最後に 中央 へ戻る

        const totalSegments = (this.lookCycles * 2) + 1; // 往復(2*cycles) + 最後の中央戻り
        const currentTotalTime = this._investElapsed;
        const totalDuration = this._totalInvestDuration;

        if (currentTotalTime >= totalDuration) {
            // 見回し完了
            this.node.setWorldRotation(this._originalRot);
            if (this.returnToStart) {
                this._changeState(InvestigateState.Returning);
            } else {
                this._changeState(InvestigateState.Idle);
            }
            return;
        }

        // どの区間にいるか
        const segmentDuration = this.lookDurationPerSide;
        const segmentIndex = Math.floor(currentTotalTime / segmentDuration);
        const segmentTime = currentTotalTime - segmentIndex * segmentDuration;
        const localT = math.clamp01(segmentTime / segmentDuration);

        const node = this.node;

        if (segmentIndex < this.lookCycles * 2) {
            // 左右の往復中
            // 偶数: 左へ / 奇数: 右へ のように切り替える
            const isEven = (segmentIndex % 2) === 0;
            if (isEven) {
                // 中央 or 右 から 左 へ
                const fromRot = (segmentIndex === 0) ? this._originalRot : this._rightRot;
                Quat.slerp(tmpQuat, fromRot, this._leftRot, localT);
                node.setWorldRotation(tmpQuat);
            } else {
                // 左 から 右 へ
                Quat.slerp(tmpQuat, this._leftRot, this._rightRot, localT);
                node.setWorldRotation(tmpQuat);
            }
        } else {
            // 最後の中央へ戻る区間
            // 右 から 中央 へ戻ると仮定
            Quat.slerp(tmpQuat, this._rightRot, this._originalRot, localT);
            node.setWorldRotation(tmpQuat);
        }
    }

    private _setupReturning() {
        // 元の位置へ戻る移動のセットアップ
        this.node.getWorldPosition(this._moveStartPos);
        this._moveTargetPos.set(this._startWorldPos);
        this._moveTotalDistance = Vec3.distance(this._moveStartPos, this._moveTargetPos);
        this._moveElapsed = 0;

        if (this._moveTotalDistance <= this.arrivalThreshold) {
            // ほぼ元位置なら即終了
            this.node.setWorldPosition(this._startWorldPos);
            this.node.setWorldRotation(this._startWorldRot);
            this._changeState(InvestigateState.Idle);
        }
    }

    private _updateReturning(deltaTime: number) {
        this._moveElapsed += deltaTime;
        const node = this.node;

        const currentPos = new Vec3();
        node.getWorldPosition(currentPos);

        const dir = new Vec3();
        Vec3.subtract(dir, this._moveTargetPos, currentPos);
        const distance = dir.length();

        if (distance <= this.arrivalThreshold) {
            node.setWorldPosition(this._moveTargetPos);
            node.setWorldRotation(this._startWorldRot);
            this._changeState(InvestigateState.Idle);
            return;
        }

        dir.normalize();
        const moveStep = this.moveSpeed * deltaTime;
        if (moveStep >= distance) {
            node.setWorldPosition(this._moveTargetPos);
            node.setWorldRotation(this._startWorldRot);
            this._changeState(InvestigateState.Idle);
        } else {
            Vec3.scaleAndAdd(currentPos, currentPos, dir, moveStep);
            node.setWorldPosition(currentPos);
        }
    }
}

// slerp 用の一時クォータニオン(GC削減)
const tmpQuat = new Quat();

コードのポイント解説

  • onLoad()
    • ノードの「初期ワールド位置」と「初期ワールド回転」を保存しています。
    • 戻る機能(returnToStart)で使用します。
    • moveSpeedlookDurationPerSide に不正な値が設定されている場合は console.error を出して防御的にチェックしています。
  • start()
    • autoStart が true のとき、自動で startInvestigation() を呼び出し、調査行動を開始します。
  • update(deltaTime)
    • _state に応じて、移動・見回し・帰還などの処理をそれぞれのメソッドに委譲しています。
    • 各処理は _updateMoving / _updateInvestigating / _updateReturning に分離されており、見通しのよい構造になっています。
  • startInvestigation(targetWorldPos?)
    • 引数でターゲット座標を渡せば、その場で targetPosition を更新してから処理を開始します。
    • 移動距離がしきい値より小さい場合は、移動せずにすぐ見回しへ移行します。
  • 見回し(_setupInvestigating / _updateInvestigating)
    • 現在の回転を基準に、左右のクォータニオンを生成しています。
    • Quat.slerp を用いて、時間経過に応じて滑らかに回転させています。
    • lookCycles で往復回数を管理し、最後は中央(元の回転)に戻ります。
    • GC削減のため、クラス外に tmpQuat を1つだけ確保し、毎フレームの一時オブジェクト生成を避けています。
  • 戻り動作(_setupReturning / _updateReturning)
    • 移動処理は _updateMoving とほぼ同じロジックですが、到達時に回転も初期値へ戻す点が異なります。

使用手順と動作確認

ここからは、Cocos Creator 3.8.7 エディタ上で実際に InvestigatePos を使う手順を説明します。

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

  1. エディタ下部の Assets パネルで、任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create → TypeScript を選択し、ファイル名を InvestigatePos.ts にします。
  3. 自動生成されたテンプレートコードをすべて削除し、本記事の 「TypeScriptコードの実装」 にあるコードを丸ごと貼り付けて保存します。

2. テスト用ノードの作成

動作確認のために、簡単なシーンを用意します。

  1. Hierarchy パネルで右クリック → Create → 3D Object → Cube を選択し、Enemy のような名前に変更します。
    • 2Dゲームの場合は Create → UI → Sprite などでも構いません。Transform を持つノードであれば問題ありません。
  2. 作成した Enemy ノードを選択し、Inspector パネルで位置を分かりやすいところに配置します。
    • 例:Position = (0, 0, 0)

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

  1. Enemy ノードを選択した状態で、Inspector パネル下部の Add Component ボタンをクリックします。
  2. Custom カテゴリから InvestigatePos を選択します。

4. プロパティの設定例

Inspector 上で、InvestigatePos の各プロパティを次のように設定してみます。

  • Auto Starttrue
    • シーン再生と同時に調査行動を開始させます。
  • Target Position
    • 例1(3D):(5, 0, 0) … X方向に 5 ユニット離れた位置へ移動。
    • 例2(2D):(300, 0, 0) … 2D UI シーンなどで画面右側へ移動させるイメージ。
  • Move Speed3
    • ゆっくり歩いていく程度の速度。
  • Arrival Threshold0.05
    • ほぼピッタリの位置で止まるようにします。
  • Look Angle45
    • 左右 45度まで首を振ります。
  • Look Duration Per Side0.5
    • 片側へ 0.5 秒で振るので、そこそこキビキビした動きになります。
  • Look Cycles2
    • 左右 2 往復分、周囲を見回します。
  • Return To Starttrue
    • 見回しが終わったら元の位置へ戻るようにします。
  • Debug Logtrue
    • 動作確認中はオンにして、状態遷移ログを確認すると分かりやすいです。

5. シーンを再生して確認

  1. エディタ上部の ▶︎ Play ボタンを押してシーンを再生します。
  2. ゲームビューを確認すると、以下のような挙動になるはずです。
    • Enemy ノードが、元の位置から Target Position まで移動する。
    • 到達後、その場で左右に首を振りながら周囲を見回す。
    • 設定した回数の見回しが終わると、元の位置へ戻る。
    • Debug Log が true の場合、[InvestigatePos] State: Idle -> MovingToTarget などのログが Console に表示される。

6. 手動で調査開始をトリガーする(任意)

他のスクリプトから動的に調査位置を変えたい場合でも、InvestigatePos 自体は他スクリプトに依存せず、公開メソッド経由で座標を渡すだけで利用可能です。

例:別スクリプトから呼び出す場合(参考用・このコンポーネント自体には含めないでください)


// 例: 何らかのトリガーが発生したときに呼び出す
const investigate = enemyNode.getComponent(InvestigatePos);
if (investigate && !investigate.isBusy()) {
    const suspiciousPos = new Vec3(10, 0, 0);
    investigate.startInvestigation(suspiciousPos);
}

このように、InvestigatePos は単体で完結しているため、どのノードにでもアタッチして使い回すことができます


まとめ

InvestigatePos コンポーネントは、「指定位置まで移動して、周囲をキョロキョロ見回す」という挙動を、1つのスクリプトをアタッチするだけで実現できる汎用コンポーネントです。

  • インスペクタから
    • 調査位置(targetPosition
    • 移動速度(moveSpeed
    • 見回し角度・速度・回数(lookAngle, lookDurationPerSide, lookCycles
    • 戻るかどうか(returnToStart

    を調整できるため、シーンごと・敵ごとに簡単に挙動をカスタマイズできます。

  • 外部の GameManager やプレイヤー管理スクリプトに依存しないため、
    • プロトタイプ段階でサクッと敵の「不審点調査」挙動を試す
    • チュートリアル用の演出として「ここを調査している敵」を簡単に配置する

    といった用途でも気軽に使い回せます。

  • 公開メソッド startInvestigation()isBusy() を用意しているので、
    • 将来的に他のAIロジックやステートマシンと連携したい場合
    • トリガーイベントに応じて調査位置を変えたい場合

    にも拡張しやすい設計になっています。

このように、単体で完結した汎用コンポーネントを積み重ねることで、ゲーム全体の実装を疎結合に保ちつつ、再利用性の高いコードベースを構築できます。
まずはこの InvestigatePos をベースに、「調査後に別のパトロールルートへ戻る」 など、あなたのゲームのAI仕様に合わせた拡張にも挑戦してみてください。