【Cocos Creator 3.8】WanderRoam(ランダム徘徊)の実装:アタッチするだけで「その場をウロウロ歩き回る」挙動を実現する汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで「待機 → ランダムな方向へ一定時間移動 → 再び待機…」という徘徊パターンを自動で繰り返す WanderRoam コンポーネントを実装します。
敵キャラ、NPC、動物、ドローンなど「その場付近をうろうろさせたい」オブジェクトに使え、外部の GameManager や AI システムに一切依存しません。すべての設定はインスペクタから調整できるように設計します。
コンポーネントの設計方針
1. 機能要件の整理
- ノードにアタッチするだけで動作する(他スクリプトへの依存なし)。
- 「待機状態」と「移動状態」を時間で切り替える。
- 移動状態では、ランダムな方向ベクトルに向かって一定速度で移動する。
- 移動と待機の時間はインスペクタから調整できる。
- 移動方向は「完全にランダム」または「前回方向から少しだけ変える」など、挙動を調整できる。
- 移動範囲を簡易的に制限するために「中心位置からの最大距離」を設定できる。
- 2D/3D どちらでも使えるように、XZ 平面と XY 平面を切り替えられるようにする。
本コンポーネントは Node の position を直接書き換えるだけで動作し、RigidBody などの物理コンポーネントに依存しません。そのため、シンプルな NPC やエフェクト用ダミーノードなど、さまざまな用途でそのまま利用できます。
2. インスペクタで設定可能なプロパティ設計
以下のようなプロパティを用意します。
- enabledWander: boolean
– デフォルト:true
– 説明: 徘徊挙動の ON/OFF 切り替え。チェックを外すとその場で停止します。 - moveSpeed: number
– デフォルト:1.5
– 説明: 移動速度(単位/秒)。大きいほど速く動きます。
– 例: 2D の小さな敵なら 1〜3、3D のキャラなら 3〜6 など。 - minMoveDuration: number
– デフォルト:0.8秒
– 説明: 1 回の「移動状態」の最小時間。 - maxMoveDuration: number
– デフォルト:1.8秒
– 説明: 1 回の「移動状態」の最大時間。
– 実際の移動時間は[minMoveDuration, maxMoveDuration]の範囲でランダム決定。 - minIdleDuration: number
– デフォルト:0.6秒
– 説明: 1 回の「待機状態」の最小時間。 - maxIdleDuration: number
– デフォルト:1.6秒
– 説明: 1 回の「待機状態」の最大時間。
– 実際の待機時間は[minIdleDuration, maxIdleDuration]の範囲でランダム決定。 - useXZPlane: boolean
– デフォルト:false
– 説明:false: 2D/トップダウン向け。XY 平面上で動く(X と Y を変化、Z は固定)。true: 3D 向け。XZ 平面上で動く(X と Z を変化、Y は固定)。
- maxTurnAngle: number
– デフォルト:180度
– 説明: 次の移動方向を決めるときに、前回方向からどれだけ曲がってよいかの最大角度。180: 完全ランダム(どの方向にも向き直る)。45: 前回の進行方向から ±45 度の範囲でのみ向きを変える → 滑らかにジグザグ。
- useWanderRadius: boolean
– デフォルト:false
– 説明: チェックを入れると「徘徊の中心位置」からの最大距離を制限します。 - wanderRadius: number
– デフォルト:5
– 説明:useWanderRadiusが有効なとき、開始位置(または指定した中心ノード)からこの距離を超えないように移動を制限します。 - wanderCenter: Node | null
– デフォルト:null
– 説明:nullの場合: このコンポーネントがアタッチされたノードの「開始位置」が中心になります。- ノードを指定した場合: そのノードの位置が常に徘徊の中心になります(親オブジェクトなど)。
- faceMoveDirection: boolean
– デフォルト:false
– 説明: 有効にすると、移動方向にノードの向きを合わせます。- 2D(XY)の場合: Z 回転(Y軸回りの回転ではなく、スプライトの「向き」になるような回転)を調整。
- 3D(XZ)の場合: Y 回転を調整。
スプライトやキャラモデルが常に進行方向を向いてほしい場合に使用します。
これらのプロパティをすべてインスペクタから調整可能にすることで、シーンごと・キャラごとに細かい挙動の違いを簡単に作り分けられます。
TypeScriptコードの実装
以下が完成した WanderRoam コンポーネントの全コードです。
import { _decorator, Component, Node, Vec3, math } from 'cc';
const { ccclass, property } = _decorator;
/**
* WanderRoam
* 一定時間ごとにランダムな方向へ移動し、待機と移動を繰り返す汎用コンポーネント。
* 他スクリプトへの依存は一切なく、アタッチするだけで動作します。
*/
@ccclass('WanderRoam')
export class WanderRoam extends Component {
@property({
tooltip: '徘徊挙動のON/OFF。falseにするとその場で停止します。',
})
public enabledWander: boolean = true;
@property({
tooltip: '移動速度(単位/秒)。値が大きいほど速く移動します。',
min: 0,
})
public moveSpeed: number = 1.5;
@property({
tooltip: '1回の「移動状態」の最小時間(秒)。',
min: 0.1,
})
public minMoveDuration: number = 0.8;
@property({
tooltip: '1回の「移動状態」の最大時間(秒)。実際の時間は最小〜最大の間でランダムに決定されます。',
min: 0.1,
})
public maxMoveDuration: number = 1.8;
@property({
tooltip: '1回の「待機状態」の最小時間(秒)。',
min: 0,
})
public minIdleDuration: number = 0.6;
@property({
tooltip: '1回の「待機状態」の最大時間(秒)。実際の時間は最小〜最大の間でランダムに決定されます。',
min: 0,
})
public maxIdleDuration: number = 1.6;
@property({
tooltip: 'XZ平面を使うかどうか。\nOFF: XY平面(2D/トップダウン向け)\nON: XZ平面(3D向け)。',
})
public useXZPlane: boolean = false;
@property({
tooltip: '次の移動方向を決めるときに、前回方向からどれだけ曲がってよいかの最大角度(度)。\n180で完全ランダム、45で±45度の範囲でのみ方向転換します。',
min: 0,
max: 180,
step: 1,
})
public maxTurnAngle: number = 180;
@property({
tooltip: '徘徊範囲を制限するかどうか。ONにすると、中心位置からwanderRadiusの距離を超えないように移動します。',
})
public useWanderRadius: boolean = false;
@property({
tooltip: '徘徊の中心からの最大距離。useWanderRadiusがONのときのみ使用されます。',
min: 0.1,
})
public wanderRadius: number = 5;
@property({
tooltip: '徘徊の中心となるノード。\n未設定(null)の場合、このコンポーネントがアタッチされたノードの開始位置が中心になります。',
})
public wanderCenter: Node | null = null;
@property({
tooltip: '移動方向にノードの向きを合わせるかどうか。\n2D: Z回転を調整\n3D: Y回転を調整',
})
public faceMoveDirection: boolean = false;
// 内部状態
private _isMoving: boolean = false;
private _stateTimer: number = 0;
private _currentStateDuration: number = 0;
private _moveDirection: Vec3 = new Vec3(1, 0, 0);
private _startCenterPos: Vec3 = new Vec3();
private _tmpPos: Vec3 = new Vec3();
private _tmpDir: Vec3 = new Vec3();
onLoad() {
// 中心位置の初期化
if (this.wanderCenter) {
this._startCenterPos.set(this.wanderCenter.worldPosition);
} else {
this._startCenterPos.set(this.node.worldPosition);
}
// プロパティの防御的補正
if (this.minMoveDuration > this.maxMoveDuration) {
const t = this.minMoveDuration;
this.minMoveDuration = this.maxMoveDuration;
this.maxMoveDuration = t;
console.warn('[WanderRoam] minMoveDuration が maxMoveDuration より大きかったため、値を入れ替えました。');
}
if (this.minIdleDuration > this.maxIdleDuration) {
const t = this.minIdleDuration;
this.minIdleDuration = this.maxIdleDuration;
this.maxIdleDuration = t;
console.warn('[WanderRoam] minIdleDuration が maxIdleDuration より大きかったため、値を入れ替えました。');
}
// 初期状態は待機からスタート
this._isMoving = false;
this._currentStateDuration = this._getRandomRange(this.minIdleDuration, this.maxIdleDuration);
this._stateTimer = 0;
// 初期の移動方向はランダムに決めておく
this._moveDirection = this._getRandomDirection();
}
start() {
// 特に必須コンポーネントはないが、警告や初期化などをここで行える
if (!this.enabledWander) {
// 無効スタート時は何もしない
return;
}
}
update(deltaTime: number) {
if (!this.enabledWander) {
return;
}
// 中心位置を更新(wanderCenterが指定されていればそれを利用)
if (this.wanderCenter) {
this._startCenterPos.set(this.wanderCenter.worldPosition);
}
this._stateTimer += deltaTime;
if (this._stateTimer >= this._currentStateDuration) {
// 状態切り替え
this._stateTimer = 0;
if (this._isMoving) {
// 移動 → 待機へ
this._isMoving = false;
this._currentStateDuration = this._getRandomRange(this.minIdleDuration, this.maxIdleDuration);
} else {
// 待機 → 移動へ
this._isMoving = true;
this._currentStateDuration = this._getRandomRange(this.minMoveDuration, this.maxMoveDuration);
// 新しい移動方向を決定
this._moveDirection = this._getNextMoveDirection();
}
}
if (this._isMoving) {
this._updateMovement(deltaTime);
}
}
/**
* 現在の移動状態に応じてノードの位置を更新する。
*/
private _updateMovement(deltaTime: number) {
if (this.moveSpeed <= 0) {
return;
}
// 現在のワールド位置を取得
this._tmpPos.set(this.node.worldPosition);
// 基本の移動量 = 方向 * 速度 * 経過時間
this._tmpDir.set(this._moveDirection);
this._tmpDir.multiplyScalar(this.moveSpeed * deltaTime);
// 平面に応じて座標を更新
if (this.useXZPlane) {
this._tmpPos.x += this._tmpDir.x;
this._tmpPos.z += this._tmpDir.z;
// Yは固定
} else {
this._tmpPos.x += this._tmpDir.x;
this._tmpPos.y += this._tmpDir.y;
// Zは固定
}
// 徘徊半径の制限がある場合、中心からの距離をチェック
if (this.useWanderRadius) {
const center = this._startCenterPos;
let distSq = 0;
if (this.useXZPlane) {
const dx = this._tmpPos.x - center.x;
const dz = this._tmpPos.z - center.z;
distSq = dx * dx + dz * dz;
} else {
const dx = this._tmpPos.x - center.x;
const dy = this._tmpPos.y - center.y;
distSq = dx * dx + dy * dy;
}
const radiusSq = this.wanderRadius * this.wanderRadius;
if (distSq > radiusSq) {
// 半径を超えそうな場合は、中心方向に向かうように移動方向を補正
this._recalculateDirectionTowardsCenter();
// 方向を更新した上で、位置は中心から半径内に収まるように再計算
if (this.useXZPlane) {
const dx = this._tmpPos.x - center.x;
const dz = this._tmpPos.z - center.z;
const len = Math.sqrt(dx * dx + dz * dz);
if (len > 0) {
const ratio = this.wanderRadius / len;
this._tmpPos.x = center.x + dx * ratio;
this._tmpPos.z = center.z + dz * ratio;
}
} else {
const dx = this._tmpPos.x - center.x;
const dy = this._tmpPos.y - center.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
const ratio = this.wanderRadius / len;
this._tmpPos.x = center.x + dx * ratio;
this._tmpPos.y = center.y + dy * ratio;
}
}
}
}
// 実際にノードのワールド位置を更新
this.node.setWorldPosition(this._tmpPos);
// 進行方向にノードの向きを合わせる
if (this.faceMoveDirection) {
this._updateRotationByDirection();
}
}
/**
* ランダムな方向ベクトルを生成する。
* useXZPlane に応じて XY または XZ 平面上の単位ベクトルを返す。
*/
private _getRandomDirection(): Vec3 {
const angleRad = math.toRadian(Math.random() * 360);
const dir = new Vec3();
if (this.useXZPlane) {
// XZ 平面上の単位ベクトル
dir.x = Math.cos(angleRad);
dir.y = 0;
dir.z = Math.sin(angleRad);
} else {
// XY 平面上の単位ベクトル
dir.x = Math.cos(angleRad);
dir.y = Math.sin(angleRad);
dir.z = 0;
}
return dir;
}
/**
* 次の移動方向を決定する。
* maxTurnAngle に応じて、前回方向からの変化量を制限する。
*/
private _getNextMoveDirection(): Vec3 {
if (this.maxTurnAngle >= 180) {
// 完全ランダム
return this._getRandomDirection();
}
// 現在の方向ベクトルを基準に、±maxTurnAngleの範囲で回転
const current = this._moveDirection.clone();
if (current.length() === 0) {
return this._getRandomDirection();
}
current.normalize();
const halfAngle = this.maxTurnAngle;
const deltaDeg = (Math.random() * 2 * halfAngle) - halfAngle; // [-halfAngle, +halfAngle]
const deltaRad = math.toRadian(deltaDeg);
const newDir = new Vec3();
if (this.useXZPlane) {
const cosA = Math.cos(deltaRad);
const sinA = Math.sin(deltaRad);
// Yは0固定のまま回転
newDir.x = current.x * cosA - current.z * sinA;
newDir.y = 0;
newDir.z = current.x * sinA + current.z * cosA;
} else {
const cosA = Math.cos(deltaRad);
const sinA = Math.sin(deltaRad);
// Zは0固定のまま回転
newDir.x = current.x * cosA - current.y * sinA;
newDir.y = current.x * sinA + current.y * cosA;
newDir.z = 0;
}
newDir.normalize();
return newDir;
}
/**
* wanderRadius を超えたとき、中心方向へ向かうように移動方向を補正する。
*/
private _recalculateDirectionTowardsCenter() {
const center = this._startCenterPos;
const pos = this.node.worldPosition;
if (this.useXZPlane) {
const dx = center.x - pos.x;
const dz = center.z - pos.z;
const len = Math.sqrt(dx * dx + dz * dz);
if (len > 0.0001) {
this._moveDirection.set(dx / len, 0, dz / len);
}
} else {
const dx = center.x - pos.x;
const dy = center.y - pos.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0.0001) {
this._moveDirection.set(dx / len, dy / len, 0);
}
}
}
/**
* 進行方向に応じてノードの回転を更新する。
* 2D: Z回転 / 3D: Y回転 を調整。
*/
private _updateRotationByDirection() {
const dir = this._moveDirection;
if (dir.length() === 0) {
return;
}
if (this.useXZPlane) {
// XZ 平面上の方向 → Y軸回りの回転(yaw)
const angleRad = Math.atan2(dir.x, dir.z); // Z前方基準
const euler = this.node.eulerAngles.clone();
euler.y = math.toDegree(angleRad);
this.node.setRotationFromEuler(euler);
} else {
// XY 平面上の方向 → Z軸回りの回転
const angleRad = Math.atan2(dir.y, dir.x); // X右方向基準
const euler = this.node.eulerAngles.clone();
euler.z = -math.toDegree(angleRad); // スプライトの向きに合わせて符号を反転
this.node.setRotationFromEuler(euler);
}
}
/**
* [min, max] の範囲でランダムな数値を返す。
*/
private _getRandomRange(min: number, max: number): number {
if (max <= min) {
return min;
}
return min + Math.random() * (max - min);
}
}
コードのポイント解説
- onLoad()
– 徘徊の中心位置(_startCenterPos)を初期化します。
–minMoveDurationとmaxMoveDuration、minIdleDurationとmaxIdleDurationの大小関係をチェックし、誤って逆に設定しても動くように防御的に補正しています。
– 初期状態を「待機」にし、次の状態切り替えまでの時間をランダムに設定します。 - update(deltaTime)
–enabledWanderが false の場合は何もせず return。
–wanderCenterが設定されている場合は、そのノードの現在位置を中心位置として毎フレーム更新します(親が動くケースなどに対応)。
– 状態タイマー_stateTimerを進め、現在の状態時間_currentStateDurationを超えたら「移動 ↔ 待機」を切り替えます。
– 移動開始時に_getNextMoveDirection()を呼んで新しい方向を決定します。 - _updateMovement(deltaTime)
– 現在の移動方向と速度から移動量を計算し、XY または XZ 平面上で位置を更新します。
–useWanderRadiusが有効な場合は、中心からの距離をチェックし、半径を超えそうなら中心方向に向かうように補正します。
–faceMoveDirectionが true の場合は、進行方向にノードの回転を合わせます。 - _getRandomDirection()
– ランダムな角度を生成し、XY または XZ 平面上の単位ベクトルとして返します。 - _getNextMoveDirection()
–maxTurnAngleが 180 度以上なら完全ランダム方向。
– それ以外なら、現在の方向ベクトルを中心に ±maxTurnAngle度の範囲で回転させた新しい方向を返します。 - _recalculateDirectionTowardsCenter()
– 徘徊半径を超えたときに、中心位置へ向かう正規化ベクトルを_moveDirectionに設定します。 - _updateRotationByDirection()
– 2D(XY)の場合は Z 回転、3D(XZ)の場合は Y 回転を進行方向に合わせます。
– 2D スプライトの向きはプロジェクトごとに異なるため、必要に応じて符号や基準軸を調整してください。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタの Assets パネルで任意のフォルダ(例:
assets/scripts)を右クリックします。 - Create → TypeScript を選択し、ファイル名を
WanderRoam.tsにします。 - 自動生成された
WanderRoam.tsをダブルクリックして開き、内容をすべて削除してから、上記の TypeScript コードをそのまま貼り付けて保存します。
2. テスト用ノードの作成(2D の例)
- 2D プロジェクトの場合:
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノード(例:
TestWanderer)を作成します。 - 作成したノードを選択し、Inspector パネルの Sprite コンポーネントで任意のスプライト画像を設定します。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノード(例:
- 3D プロジェクトの場合:
- Hierarchy パネルで右クリック → Create → 3D Object → Capsule(または Box, Sphere など) を選択し、テスト用の 3D ノードを作成します。
3. WanderRoam コンポーネントをアタッチ
- 徘徊させたいノード(例:
TestWanderer)を Hierarchy から選択します。 - Inspector パネルの下部にある Add Component ボタンをクリックします。
- Custom カテゴリから WanderRoam を探してクリックし、アタッチします。
※もしリストに表示されない場合は、スクリプト保存後にエディタがコンパイルを終えるのを待つか、一度エディタを再起動してみてください。
4. プロパティの設定例(2D / XY 平面)
2D(トップダウンやサイドビュー)での設定例:
- enabledWander: チェック ON(デフォルトのまま)。
- moveSpeed:
2.0〜3.0程度に設定。 - minMoveDuration:
0.8 - maxMoveDuration:
1.8 - minIdleDuration:
0.4 - maxIdleDuration:
1.2 - useXZPlane: チェック OFF(XY 平面で動かしたいので)。
- maxTurnAngle:
120(少し滑らかに方向転換させたい場合)。 - useWanderRadius: 必要に応じて ON。
- ON にする場合は wanderRadius を
3〜5程度に設定。 - wanderCenter はとりあえず
nullのままでOK(現在位置が中心になります)。
- ON にする場合は wanderRadius を
- faceMoveDirection: スプライトが進行方向を向いてほしい場合は ON。
その際、スプライト画像の「右向き」や「上向き」がプロジェクトによって異なるため、向きが逆の場合は_updateRotationByDirection()内の符号を調整してください。
5. プロパティの設定例(3D / XZ 平面)
3D キャラを地面の上で徘徊させる設定例:
- enabledWander: ON
- moveSpeed:
3.0〜5.0 - useXZPlane: ON(XZ 平面上で動かす)
- maxTurnAngle:
60(あまり急旋回させない) - useWanderRadius: ON
- wanderRadius:
8〜12(キャラの徘徊エリアの広さに応じて) - wanderCenter:
- シーン中央付近の空ノードを作成し、それを指定すると「この空ノードの周りを徘徊」させられます。
- 空ノードを動かすと、徘徊エリア全体がゆっくり移動するような演出も可能です。
- faceMoveDirection: ON にするとキャラが進行方向を向きます。
キャラクターモデルの「前方向」が Z+ でない場合は、モデル自体の向きを調整するか、_updateRotationByDirection()の計算をプロジェクトに合わせて変更してください。
6. 動作確認
- 設定が完了したら、エディタ右上の Play ボタンを押してプレビューを開始します。
- 徘徊させたノードが「一定時間待機 → ランダムな方向へ移動 → 再び待機…」を繰り返し、その場付近をうろうろ動き回ることを確認します。
- useWanderRadius を ON にしている場合は、中心位置から大きく離れないことも確認してください。
- 挙動が激しすぎる場合は moveSpeed を下げる、または maxTurnAngle を小さくして方向転換を緩やかにしてみてください。
まとめ
WanderRoam コンポーネントは、
- アタッチするだけで NPC や敵キャラを「それっぽく徘徊」させられる。
- 外部の GameManager や AI システムに一切依存しない。
- インスペクタから速度・待機時間・方向転換のしやすさ・徘徊範囲・向き合わせなどを細かく調整できる。
といった特徴を持つ、汎用性の高い移動コンポーネントです。
このスクリプト単体で「とりあえず動いているキャラ」を簡単に量産できるため、
- プロトタイピング段階でマップに NPC を散らして雰囲気を出す。
- 特定エリア内を巡回する小動物やドローンを作る。
- プレイヤーが近づくまで適当に動いている敵キャラを用意する。
といった用途にすぐ使えます。
また、将来的に複雑な AI を導入する場合でも、「待機とランダム移動」という基本挙動をこのコンポーネントに任せることで、上位の制御ロジックをシンプルに保つことができます。
プロジェクトに合わせてパラメータを調整しながら、さまざまなオブジェクトに WanderRoam をアタッチしてみてください。




