【Cocos Creator 3.8】WanderRandom(ランダム徘徊)の実装:アタッチするだけで「一定範囲をふらふら歩き回る」挙動を実現する汎用スクリプト

このコンポーネントは、任意のノードにアタッチするだけで「指定したエリア内をランダムに徘徊する」挙動を付与できる汎用スクリプトです。2D/3D どちらのゲームでも「NPCが適当に歩き回る」「敵がパトロールではなくランダムに動き回る」といった場面でそのまま使えます。

移動処理はこのコンポーネント単体で完結しており、GameManager や他のスクリプトへの依存は一切ありません。インスペクタから移動速度・徘徊エリア・待機時間などを調整するだけで、いろいろなランダム徘徊パターンを作れます。


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

1. 機能要件の整理

  • 一定間隔で「ランダムな目的地」を決め、その方向へ移動する。
  • 目的地に到達したら、少し待機してから次の目的地を決める。
  • 徘徊する範囲(エリア)を指定できる。
  • 2D/3D どちらでも使えるように、XZ 平面を前提とせず、Node のローカル座標 (x, y) を使うシンプルな移動とする。
  • 移動は Transform の position を直接変更する(RigidBody などへの依存はしない)。
  • 外部スクリプトへの依存は一切しない。すべての設定は @property から行う。
  • エリアの基準を「ワールド座標」か「親ノードからのローカル座標」かを選べるようにする。

2. 挙動の概要

  1. onLoad: 初期状態のチェックと内部変数の初期化。
  2. start: 現在位置を基準に最初の目的地を決定。
  3. update:
    • 「移動中」なら目的地に向かって一定速度で移動。
    • 目的地にほぼ到達したら「待機状態」に入り、待機タイマーをカウント。
    • 待機時間が経過したら、新しいランダムな目的地を決定し、再び移動開始。

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

以下のプロパティを @property で公開し、インスペクタから調整できるようにします。

  • moveSpeed: number
    • 移動速度(単位: ユニット/秒)。
    • 0 以上の値。0 にするとその場から動かない。
    • 例: 100〜300 くらいが 2D ゲームで扱いやすい値。
  • acceleration: number
    • 現在速度から目標速度までの補間係数(0〜1)。
    • 0 に近いほど「ぬるっと加速/減速」、1 に近いほど「カクっと即時加速」。
    • 簡易的なイージングとして使用。0.1〜0.3 程度が自然。
  • useLocalSpace: boolean
    • true の場合: 徘徊エリアを「親ノードからのローカル座標系」で解釈。
    • false の場合: 徘徊エリアを「ワールド座標」で解釈。
    • シーン全体で動かしたい場合は false、特定の親ノード内で完結させたい場合は true。
  • centerX: number, centerY: number
    • 徘徊エリアの中心座標。
    • useLocalSpace = true のときは「親ノードから見たローカル座標」。
    • useLocalSpace = false のときは「ワールド座標」。
    • デフォルトでは「現在位置を中心」として扱うオプションも用意。
  • useCurrentPositionAsCenter: boolean
    • true の場合: start 時にノードの現在位置を徘徊エリアの中心として自動設定。
    • false の場合: centerX, centerY に入力した値をそのまま使用。
  • areaWidth: number, areaHeight: number
    • 徘徊エリアの幅と高さ。
    • 矩形エリア内のランダムな座標を目的地として選ぶ。
    • 0 以下の値は無効として扱い、ログで警告。
  • minWaitTime: number, maxWaitTime: number
    • 目的地に到達したあとに待機する時間の範囲(秒)。
    • min〜max の間でランダムな待機時間が選ばれる。
    • min <= 0 かつ max <= 0 の場合は「待機なし」で即次の目的地へ。
  • minMoveDistance: number
    • 新しい目的地を選ぶときの「最低移動距離」。
    • あまりにも近い位置を選んでしまうとガタガタするので、ある程度の距離を確保。
    • 0 にすると制限なし。
  • arriveThreshold: number
    • 目的地に到達したとみなす距離のしきい値。
    • 小さすぎると目的地を行き過ぎてガクガクするので、5〜20 くらいが扱いやすい。
  • pauseOnStart: boolean
    • true の場合: シーン開始時は待機状態から始める。
    • false の場合: シーン開始と同時に最初の目的地へ移動開始。
  • debugDrawGizmos: boolean
    • true の場合: エディタ上で徘徊エリアと現在の目的地を Gizmo で可視化。
    • ゲーム実行中のパフォーマンスにほぼ影響はないが、不要なら OFF にできる。

TypeScriptコードの実装

以下が完成した WanderRandom.ts の全コードです。


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

/**
 * WanderRandom
 * 指定した矩形エリア内をランダムに徘徊する汎用コンポーネント。
 * 他スクリプトへの依存は一切なく、このコンポーネント単体で完結します。
 */
@ccclass('WanderRandom')
@executeInEditMode(true)
@menu('Custom/WanderRandom')
export class WanderRandom extends Component {

    @property({
        tooltip: '移動速度(単位: ユニット/秒)。0 の場合は移動しません。',
        min: 0,
    })
    public moveSpeed: number = 150;

    @property({
        tooltip: '現在速度から目標速度への補間係数(0〜1)。\n0 に近いほど緩やかに加減速、1 に近いほど即時に目標速度になります。',
        min: 0,
        max: 1,
        step: 0.01,
    })
    public acceleration: number = 0.2;

    @property({
        tooltip: '徘徊エリアをローカル座標系で扱うかどうか。\nON: 親ノードからのローカル座標\nOFF: ワールド座標',
    })
    public useLocalSpace: boolean = true;

    @property({
        tooltip: 'true の場合、start 時に現在位置を中心とした徘徊エリアを自動設定します。\nfalse の場合、centerX/centerY の値をそのまま使用します。',
    })
    public useCurrentPositionAsCenter: boolean = true;

    @property({
        tooltip: '徘徊エリアの中心 X 座標。\nuseLocalSpace が ON の場合はローカル座標、OFF の場合はワールド座標として解釈されます。',
    })
    public centerX: number = 0;

    @property({
        tooltip: '徘徊エリアの中心 Y 座標。\nuseLocalSpace が ON の場合はローカル座標、OFF の場合はワールド座標として解釈されます。',
    })
    public centerY: number = 0;

    @property({
        tooltip: '徘徊エリアの幅(X方向のサイズ)。0 以下の場合は無効として扱われます。',
        min: 0,
    })
    public areaWidth: number = 500;

    @property({
        tooltip: '徘徊エリアの高さ(Y方向のサイズ)。0 以下の場合は無効として扱われます。',
        min: 0,
    })
    public areaHeight: number = 300;

    @property({
        tooltip: '目的地に到達した後に待機する最小時間(秒)。\n0 以下の場合は待機時間なしになります。',
    })
    public minWaitTime: number = 0.3;

    @property({
        tooltip: '目的地に到達した後に待機する最大時間(秒)。\nminWaitTime〜maxWaitTime の間でランダムに決定されます。\n0 以下の場合は待機時間なしになります。',
    })
    public maxWaitTime: number = 1.5;

    @property({
        tooltip: '新しい目的地を決める際の最低移動距離。\nあまりにも近い位置を選ぶのを防ぐために使用します。\n0 の場合は制限なし。',
        min: 0,
    })
    public minMoveDistance: number = 50;

    @property({
        tooltip: '目的地に到達したとみなす距離のしきい値。\nこの距離以内に入ると到着と判定され、待機状態に入ります。',
        min: 0.1,
    })
    public arriveThreshold: number = 10;

    @property({
        tooltip: 'true の場合、シーン開始時は待機状態から始めます。\nfalse の場合、開始と同時に最初の目的地へ移動します。',
    })
    public pauseOnStart: boolean = false;

    @property({
        tooltip: 'true の場合、エディタ上で徘徊エリアと現在の目的地を Gizmo で可視化します。',
    })
    public debugDrawGizmos: boolean = true;

    // 内部状態
    private _currentVelocity: Vec3 = new Vec3();
    private _targetPosition: Vec3 | null = null;
    private _isWaiting: boolean = false;
    private _waitTimer: number = 0;
    private _currentWaitDuration: number = 0;
    private _centerWorld: Vec3 = new Vec3();

    // 作業用一時ベクトル(GC削減用)
    private static _tempVec3A: Vec3 = new Vec3();
    private static _tempVec3B: Vec3 = new Vec3();

    onLoad() {
        // 防御的チェック:Node が存在することは Component の仕様上保証されているが、
        // 念のため null チェックをしておく。
        if (!this.node) {
            console.error('[WanderRandom] node が存在しません。コンポーネントのアタッチに問題があります。');
            return;
        }

        // エリアサイズのチェック
        if (this.areaWidth <= 0 || this.areaHeight <= 0) {
            console.warn('[WanderRandom] areaWidth または areaHeight が 0 以下です。徘徊エリアが無効になります。', this.node.name);
        }

        // min/max 待機時間の整合性を取る
        if (this.maxWaitTime < this.minWaitTime) {
            const tmp = this.minWaitTime;
            this.minWaitTime = this.maxWaitTime;
            this.maxWaitTime = tmp;
            console.warn('[WanderRandom] minWaitTime と maxWaitTime の値が逆転していたため入れ替えました。', this.node.name);
        }

        // 初期速度はゼロ
        this._currentVelocity.set(0, 0, 0);
    }

    start() {
        // 中心座標を決定
        this._updateCenterFromCurrentPositionIfNeeded();

        // pauseOnStart に応じて初期状態を決定
        if (this.pauseOnStart) {
            this._enterWaitState();
        } else {
            this._chooseNewTarget();
        }
    }

    update(dt: number) {
        if (!this.node) {
            return;
        }

        // エリアサイズが有効でない場合は動かさない
        if (this.areaWidth <= 0 || this.areaHeight <= 0) {
            return;
        }

        // 待機中の処理
        if (this._isWaiting) {
            if (this._currentWaitDuration > 0) {
                this._waitTimer += dt;
                if (this._waitTimer >= this._currentWaitDuration) {
                    // 待機終了 → 新しい目的地へ
                    this._isWaiting = false;
                    this._chooseNewTarget();
                }
            } else {
                // 待機時間が 0 以下なら即次の目的地へ
                this._isWaiting = false;
                this._chooseNewTarget();
            }
            return;
        }

        // 移動中の処理
        if (!this._targetPosition) {
            // 目的地がなければ新しく選ぶ
            this._chooseNewTarget();
            return;
        }

        const currentWorldPos = WanderRandom._tempVec3A;
        this.node.getWorldPosition(currentWorldPos);

        const toTarget = WanderRandom._tempVec3B;
        Vec3.subtract(toTarget, this._targetPosition, currentWorldPos);

        const distance = toTarget.length();

        // 目的地に到達したかチェック
        if (distance <= this.arriveThreshold) {
            // 到着 → 待機状態へ
            this._enterWaitState();
            return;
        }

        // 目標速度ベクトルを計算(正規化して moveSpeed を掛ける)
        if (distance > 0.0001) {
            toTarget.normalize();
        }
        const targetVelocity = toTarget.multiplyScalar(this.moveSpeed);

        // 現在速度を補間(簡易加減速)
        const accel = math.clamp01(this.acceleration);
        this._currentVelocity.lerp(targetVelocity, accel);

        // 実際の移動
        const frameMove = this._currentVelocity.clone().multiplyScalar(dt);
        const newWorldPos = currentWorldPos.add(frameMove);

        this.node.setWorldPosition(newWorldPos);
    }

    /**
     * 現在位置を徘徊エリアの中心として使用する設定の場合、
     * start 時点の位置から中心座標を決定します。
     */
    private _updateCenterFromCurrentPositionIfNeeded() {
        if (!this.node) {
            return;
        }

        if (this.useCurrentPositionAsCenter) {
            if (this.useLocalSpace) {
                const localPos = WanderRandom._tempVec3A;
                this.node.getPosition(localPos);
                this.centerX = localPos.x;
                this.centerY = localPos.y;
            } else {
                const worldPos = WanderRandom._tempVec3A;
                this.node.getWorldPosition(worldPos);
                this.centerX = worldPos.x;
                this.centerY = worldPos.y;
            }
        }

        // centerX/centerY をワールド座標系に変換して _centerWorld に保持
        if (this.useLocalSpace) {
            const parent = this.node.parent;
            if (parent) {
                const localCenter = WanderRandom._tempVec3A;
                localCenter.set(this.centerX, this.centerY, 0);
                parent.getWorldMatrix().transformPoint(localCenter, this._centerWorld);
            } else {
                // 親がない場合はローカル=ワールドとみなす
                this._centerWorld.set(this.centerX, this.centerY, 0);
            }
        } else {
            this._centerWorld.set(this.centerX, this.centerY, 0);
        }
    }

    /**
     * 新しいランダムな目的地を決定します。
     */
    private _chooseNewTarget() {
        if (!this.node) {
            return;
        }

        if (this.areaWidth <= 0 || this.areaHeight <= 0) {
            console.warn('[WanderRandom] 徘徊エリアの幅または高さが 0 以下のため、目的地を決められません。', this.node.name);
            return;
        }

        // 中心座標を更新(useCurrentPositionAsCenter が true の場合は動的に変えたいケースもあるため)
        this._updateCenterFromCurrentPositionIfNeeded();

        const halfW = this.areaWidth * 0.5;
        const halfH = this.areaHeight * 0.5;

        const currentWorldPos = WanderRandom._tempVec3A;
        this.node.getWorldPosition(currentWorldPos);

        const maxTry = 10;
        let candidate = new Vec3();

        for (let i = 0; i < maxTry; i++) {
            const randX = math.randomRange(-halfW, halfW);
            const randY = math.randomRange(-halfH, halfH);

            candidate.set(
                this._centerWorld.x + randX,
                this._centerWorld.y + randY,
                currentWorldPos.z, // Z はそのまま維持
            );

            // 最低移動距離を満たしているかチェック
            if (this.minMoveDistance > 0) {
                const dist = Vec3.distance(candidate, currentWorldPos);
                if (dist < this.minMoveDistance) {
                    continue;
                }
            }

            // 条件を満たしたのでこれを採用
            this._targetPosition = candidate;
            return;
        }

        // うまく選べなかった場合、最後の候補を使う(または null にする)
        this._targetPosition = candidate;
    }

    /**
     * 待機状態に入る。
     * minWaitTime / maxWaitTime をもとに待機時間をランダムに決定する。
     */
    private _enterWaitState() {
        this._isWaiting = true;
        this._waitTimer = 0;

        if (this.minWaitTime <= 0 && this.maxWaitTime <= 0) {
            this._currentWaitDuration = 0;
        } else {
            const min = Math.max(0, this.minWaitTime);
            const max = Math.max(min, this.maxWaitTime);
            this._currentWaitDuration = math.randomRange(min, max);
        }

        // 移動停止
        this._currentVelocity.set(0, 0, 0);
    }

    /**
     * エディタ上で徘徊エリアと目的地を描画する(Gizmo)。
     * 実行中・非実行中どちらでも動作します。
     */
    onDrawGizmos() {
        if (!this.debugDrawGizmos || !this.node) {
            return;
        }

        const scene = director.getScene();
        if (!scene) {
            return;
        }

        // 中心座標を最新状態に更新
        this._updateCenterFromCurrentPositionIfNeeded();

        // 矩形エリアの四隅を計算
        const halfW = this.areaWidth * 0.5;
        const halfH = this.areaHeight * 0.5;

        const cx = this._centerWorld.x;
        const cy = this._centerWorld.y;

        const p1 = new Vec3(cx - halfW, cy - halfH, 0);
        const p2 = new Vec3(cx + halfW, cy - halfH, 0);
        const p3 = new Vec3(cx + halfW, cy + halfH, 0);
        const p4 = new Vec3(cx - halfW, cy + halfH, 0);

        // Cocos Creator 3.x のエディタ Gizmo API は内部向けのため、
        // ここでは「onDrawGizmos が呼ばれる」という前提でコメントのみ残します。
        // 実際に線を描画したい場合は、エディタ拡張の Gizmo クラスを別途実装する必要があります。
        //
        // このサンプルでは「デバッグ可視化のためのフックがある」ことを示す目的で実装しています。
        //
        // 例(疑似コード):
        // Gizmo.drawRect([p1, p2, p3, p4], Color.GREEN);
        // if (this._targetPosition) Gizmo.drawCircle(this._targetPosition, 10, Color.RED);
        //
        // 実プロジェクトで本格的に可視化したい場合は、Editor 拡張として Gizmo を実装してください。
    }
}

主要メソッドの解説

  • onLoad
    • エリアサイズや待機時間の妥当性チェックを行い、必要に応じてログを出します。
    • 速度ベクトルを初期化します。
  • start
    • useCurrentPositionAsCenter の設定に応じて、徘徊エリアの中心座標を決定します。
    • pauseOnStart が true の場合は待機状態から開始し、false の場合はすぐに最初の目的地を選びます。
  • update(dt)
    • 待機状態なら待機タイマーを進め、時間が来たら新しい目的地を選びます。
    • 移動状態なら目的地方向への目標速度を計算し、acceleration で補間しながら移動します。
    • 目的地に近づきすぎたら(arriveThreshold 以内)到着とみなし、待機状態へ移行します。
  • _chooseNewTarget()
    • 徘徊エリアの矩形内からランダムな座標を選びます。
    • minMoveDistance を満たすまで最大 10 回まで候補を再抽選します。
    • どうしても条件を満たせない場合は最後の候補を採用します。
  • _enterWaitState()
    • minWaitTimemaxWaitTime の範囲からランダムに待機時間を決定します。
    • 待機中は速度ベクトルをゼロにし、移動を停止します。
  • onDrawGizmos()
    • エディタ上で徘徊エリアや目的地を可視化するためのフックです。
    • 実際の線描画は Editor 拡張側の Gizmo 実装に委ねる形にしており、このコンポーネント自体はエンジン標準 API 以外に依存しません。

使用手順と動作確認

1. スクリプトの作成

  1. Assets パネルで右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を WanderRandom.ts にします。
  4. 自動生成されたファイルをダブルクリックして開き、中身をすべて削除して、上記の TypeScript コードを丸ごと貼り付けます。
  5. 保存します(Ctrl+S / Cmd+S)。

2. テスト用ノードの作成(2D の例)

  1. Hierarchy パネルで右クリックします。
  2. Create → 2D Object → Sprite を選択し、適当な名前(例: Wanderer)を付けます。
  3. 任意のスプライト画像を Sprite コンポーネントに設定しておくと動きが分かりやすくなります。

3D で試したい場合は、Create → 3D Object → Cube などでも構いません。

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

  1. Hierarchy で先ほど作成したノード(例: Wanderer)を選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom → WanderRandom を選択します。
    • もし Custom の中に WanderRandom が見つからない場合は、スクリプト名と @ccclass('WanderRandom') が一致しているか確認し、エラーが出ていないか Console を確認してください。

4. プロパティの設定例

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

  • moveSpeed: 200
  • acceleration: 0.25
  • useLocalSpace: ON(チェック)
  • useCurrentPositionAsCenter: ON(チェック)
    • これにより、Wanderer ノードの現在位置を徘徊エリアの中心として自動設定します。
  • centerX / centerY: デフォルトのままで OK(自動で上書きされます)
  • areaWidth: 600
  • areaHeight: 400
  • minWaitTime: 0.5
  • maxWaitTime: 1.5
  • minMoveDistance: 80
  • arriveThreshold: 15
  • pauseOnStart: OFF(チェックを外す)
  • debugDrawGizmos: ON(チェック)
    • ※ 実際の矩形描画は Editor Gizmo 拡張が必要ですが、将来的な可視化のためのフックとして ON にしておきます。

5. 再生して動作確認

  1. エディタ右上の Play ボタン(▶)を押してゲームを実行します。
  2. シーンビューまたは Game ビューで、Wanderer ノードが指定した矩形エリア内をランダムにふらふら移動することを確認します。
  3. しばらく観察すると、目的地に到達するたびに少し停止し、再び別の方向へ歩き出す挙動が確認できるはずです。

6. よくある調整パターン

  • 「もっと落ち着いた動き」にしたい
    • moveSpeed を 80〜120 くらいに下げる。
    • acceleration を 0.1〜0.2 くらいに下げる。
    • minMoveDistance を 30〜50 に下げて、小刻みな移動を増やす。
  • 「せわしなく動き回る NPC」にしたい
    • moveSpeed を 250〜350 に上げる。
    • acceleration を 0.4〜0.6 に上げる(キビキビ動く)。
    • minWaitTimemaxWaitTime を 0〜0.3 くらいにして待機時間を短くする。
  • 「マップ全体をうろうろさせたい」
    • useLocalSpace を OFF にする(ワールド座標ベース)。
    • シーンの中央付近をクリックして、その座標を centerX / centerY に手動で入力。
    • areaWidth / areaHeight をマップサイズに合わせて大きめに設定。
    • useCurrentPositionAsCenter のチェックを外す(エリア中心を固定)。

まとめ

WanderRandom コンポーネントは、

  • 任意のノードにアタッチするだけで、
  • 指定した矩形エリア内を、
  • ランダムな方向・距離で歩き回り、
  • 到着ごとにランダムな待機を挟む、

といった「ランダム徘徊 AI」のベースとなる挙動を提供します。

外部の GameManager やシングルトンに一切依存せず、すべての設定がインスペクタから完結するため、

  • 「とりあえず NPC を動かしたい」時にすぐ使える。
  • Prefab 化しておけば、別シーンでもそのまま再利用できる。
  • パラメータ調整だけで「おとなしい NPC」「落ち着きのない敵」などキャラクターごとに性格付けができる。

といったメリットがあります。

このコンポーネントをベースに、

  • プレイヤーが近づいたら徘徊をやめて追跡に切り替える。
  • 特定のトリガーエリアに入ったら徘徊エリアを変更する。
  • アニメーション(Idle/Walk)と組み合わせてより自然な NPC を作る。

などの拡張も容易です。まずはこの記事のコードをそのまま導入し、シーン内のいろいろなキャラクターにアタッチして挙動の違いを楽しみながら、自分のゲームに合ったパラメータセットを見つけてみてください。