【Cocos Creator 3.8】InvestigatePosの実装:アタッチするだけで「指定位置まで移動して周囲をキョロキョロ見回す」挙動を実現する汎用スクリプト
このコンポーネントは、敵キャラや警備ロボットなどに「不審な位置まで移動して、周囲を見回す」挙動を簡単に付与するための汎用スクリプトです。
任意のノード(例: 敵キャラのルートノード)にアタッチし、インスペクタで「調査対象位置」や「移動速度」「見回す回数」などを設定するだけで動作します。
他のカスタムスクリプトやシングルトンに一切依存せず、単体で完結するように設計しています。
コンポーネントの設計方針
1. 機能要件の整理
InvestigatePos コンポーネントは、以下の流れで動作します。
- 開始位置から「調査位置」まで移動する
- インスペクタで指定した
targetPosition(Vec3)まで、一定速度で移動。 - 到達判定は「距離がしきい値以下になったら到達」とする。
- インスペクタで指定した
- 到達後、周囲をキョロキョロ見回す
- 左右に首(ノードのY軸回転)を振る。
- 「左右に何回振るか」「何度まで振るか」「一往復にかける時間」をインスペクタから調整可能にする。
- 見回し中は移動しない。
- 見回し完了後の挙動
- オプションで「元の位置に戻る」か「その場で終了」かを選択可能にする。
- 終了後は何もしない(ステートは 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)で使用します。 moveSpeedやlookDurationPerSideに不正な値が設定されている場合は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. スクリプトファイルの作成
- エディタ下部の Assets パネルで、任意のフォルダ(例:
assets/scripts)を右クリックします。 - Create → TypeScript を選択し、ファイル名を
InvestigatePos.tsにします。 - 自動生成されたテンプレートコードをすべて削除し、本記事の 「TypeScriptコードの実装」 にあるコードを丸ごと貼り付けて保存します。
2. テスト用ノードの作成
動作確認のために、簡単なシーンを用意します。
- Hierarchy パネルで右クリック → Create → 3D Object → Cube を選択し、
Enemyのような名前に変更します。- 2Dゲームの場合は Create → UI → Sprite などでも構いません。Transform を持つノードであれば問題ありません。
- 作成した
Enemyノードを選択し、Inspector パネルで位置を分かりやすいところに配置します。- 例:
Position = (0, 0, 0)
- 例:
3. InvestigatePos コンポーネントをアタッチ
Enemyノードを選択した状態で、Inspector パネル下部の Add Component ボタンをクリックします。- Custom カテゴリから InvestigatePos を選択します。
4. プロパティの設定例
Inspector 上で、InvestigatePos の各プロパティを次のように設定してみます。
Auto Start:true- シーン再生と同時に調査行動を開始させます。
Target Position:- 例1(3D):
(5, 0, 0)… X方向に 5 ユニット離れた位置へ移動。 - 例2(2D):
(300, 0, 0)… 2D UI シーンなどで画面右側へ移動させるイメージ。
- 例1(3D):
Move Speed:3- ゆっくり歩いていく程度の速度。
Arrival Threshold:0.05- ほぼピッタリの位置で止まるようにします。
Look Angle:45- 左右 45度まで首を振ります。
Look Duration Per Side:0.5- 片側へ 0.5 秒で振るので、そこそこキビキビした動きになります。
Look Cycles:2- 左右 2 往復分、周囲を見回します。
Return To Start:true- 見回しが終わったら元の位置へ戻るようにします。
Debug Log:true- 動作確認中はオンにして、状態遷移ログを確認すると分かりやすいです。
5. シーンを再生して確認
- エディタ上部の ▶︎ Play ボタンを押してシーンを再生します。
- ゲームビューを確認すると、以下のような挙動になるはずです。
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仕様に合わせた拡張にも挑戦してみてください。
