【Cocos Creator 3.8】AggroSystem(敵対管理)の実装:アタッチするだけで「プレイヤーが範囲に入ったら追跡する敵AIステート」を実現する汎用スクリプト
このガイドでは、Cocos Creator 3.8.7 と TypeScript で「AggroSystem(敵対管理)」コンポーネントを実装します。
敵キャラのノードにこのスクリプトをアタッチし、プレイヤー候補となるノードをインスペクタから指定するだけで、
- プレイヤーが指定範囲内に入ったら「追跡モード」に切り替え
- 範囲外に出たら「待機/パトロールモード」に戻る
- モードに応じて自動的に移動処理を切り替え
といった、シンプルな敵AIのステート管理が実現できます。
外部の GameManager やシングルトンに一切依存せず、インスペクタの設定だけで完結する設計です。
コンポーネントの設計方針
1. 機能要件の整理
AggroSystem コンポーネントは、以下のような状況を想定しています。
- このコンポーネントをアタッチしたノード = 敵キャラ
- Inspector から「プレイヤー候補ノード」を複数登録できる
- 敵からの距離が最も近いプレイヤーのみを「ターゲット」として扱う
- ターゲットが「索敵範囲」内にいるときは「追跡モード」
- ターゲットが「索敵範囲」外にいるときは「待機モード」
- 追跡モード中はターゲットの方向へ移動する(速度指定)
- 待機モード中は「初期位置」へ戻るか、その場に留まる(オプション)
また、外部依存をなくすために、
- プレイヤー情報はすべてインスペクタで設定(Node の参照)
- 移動処理は Transform の position を直接更新(RigidBody などは必須にしない)
- 現在のステート(Idle / Chasing)を他コンポーネントから参照できるように public getter を用意
といった方針で設計します。
2. ステート定義
内部的には、以下の 2 ステートだけを扱います。
- Idle(待機):索敵範囲内にターゲットがいない状態
- Chasing(追跡):索敵範囲内にターゲットがいる状態
シンプルな 2 状態ですが、これだけでも多くの 2D/3D アクションゲームで使える汎用的な敵AIの土台になります。
3. インスペクタで設定可能なプロパティ設計
以下のプロパティを用意します。
- players:
Node[]
– 敵が追跡対象とする「プレイヤー候補」のノード配列
– 複数登録しておくと、常に最も近いプレイヤーを自動で選択します。 - aggroRadius:
number
– 索敵範囲の半径(ワールド座標ベース)
– この距離以内にプレイヤーが入ると「追跡モード」に切り替えます。 - loseAggroRadius:
number
– 敵対解除の半径(ヒステリシス用)
– 追跡中にこの距離を超えると「待機モード」に戻ります。
–loseAggroRadius >= aggroRadiusにしておくと、範囲の出入りでステートがガチャガチャ切り替わるのを防げます。 - moveSpeed:
number
– 追跡時の移動速度(1秒あたりのユニット距離) - returnToOriginOnIdle:
boolean
– 待機モード時に「初期位置」へ戻るかどうか
– ON の場合、追跡終了後に元の位置へゆっくり移動します。 - idleMoveSpeed:
number
– 待機モードで初期位置へ戻る際の移動速度 - debugDrawGizmos:
boolean
– シーンビュー上に索敵範囲を可視化するかどうか
– エディタ上の Scene ビューでのみ表示され、ゲームには影響しません。 - debugColorAggro:
Color
– 索敵範囲(aggroRadius)の表示色 - debugColorLose:
Color
– 敵対解除範囲(loseAggroRadius)の表示色 - enableVerticalMovement:
boolean
– 2D 横スクロールなどで「X 方向だけ追跡したい」ケースに対応するためのフラグ
– OFF の場合、Y 座標は変えずに X 方向のみ追跡します。
これらはすべて @property で Inspector から編集可能にし、ツールチップも付けて分かりやすくします。
TypeScriptコードの実装
以下が完成した AggroSystem コンポーネントの全コードです。
import { _decorator, Component, Node, Vec3, math, Color, Gizmo, geometry, director } from 'cc';
const { ccclass, property, executeInEditMode, menu } = _decorator;
enum AggroState {
Idle = 0,
Chasing = 1,
}
@ccclass('AggroSystem')
@executeInEditMode(true)
@menu('Custom/AggroSystem')
export class AggroSystem extends Component {
@property({
type: [Node],
tooltip: '追跡対象となるプレイヤー候補のノード一覧。\n複数登録すると、常に最も近いノードをターゲットとして扱います。'
})
public players: Node[] = [];
@property({
tooltip: '索敵範囲の半径。この距離以内にプレイヤーが入ると追跡モードに切り替わります。'
})
public aggroRadius: number = 5.0;
@property({
tooltip: '敵対解除の半径。追跡中にこの距離を超えると待機モードに戻ります。\naggroRadius より少し大きめに設定することで、出入り時のガタガタ切り替えを防げます。'
})
public loseAggroRadius: number = 6.0;
@property({
tooltip: '追跡モード中の移動速度(1秒あたりのユニット距離)。'
})
public moveSpeed: number = 3.0;
@property({
tooltip: '待機モード時に初期位置へ戻るかどうか。ON の場合、追跡終了後に元の位置へ移動します。'
})
public returnToOriginOnIdle: boolean = true;
@property({
tooltip: '待機モード中に初期位置へ戻るときの移動速度。'
})
public idleMoveSpeed: number = 2.0;
@property({
tooltip: 'シーンビュー上に索敵範囲と敵対解除範囲のギズモを描画するかどうか(エディタ表示のみ)。'
})
public debugDrawGizmos: boolean = true;
@property({
tooltip: '索敵範囲(aggroRadius)の表示色。',
})
public debugColorAggro: Color = new Color(0, 255, 0, 128);
@property({
tooltip: '敵対解除範囲(loseAggroRadius)の表示色。',
})
public debugColorLose: Color = new Color(255, 0, 0, 128);
@property({
tooltip: '垂直方向(Y軸)も追跡に含めるかどうか。\n2D横スクロールなどで X 方向だけ追跡したい場合は OFF にします。'
})
public enableVerticalMovement: boolean = true;
// === 内部状態 ===
private _state: AggroState = AggroState.Idle;
private _currentTarget: Node | null = null;
private _originPosition: Vec3 = new Vec3();
private _tempVec: Vec3 = new Vec3();
/**
* 現在のステートを外部から参照するための getter。
* 例: if (aggroSystem.currentState === AggroState.Chasing) { ... }
*/
public get currentState(): AggroState {
return this._state;
}
/**
* 現在追跡しているターゲットノード。
* いない場合は null。
*/
public get currentTarget(): Node | null {
return this._currentTarget;
}
onLoad() {
// 初期位置を記録
this.node.getWorldPosition(this._originPosition);
// プロパティの防御的な補正
if (this.loseAggroRadius < this.aggroRadius) {
this.loseAggroRadius = this.aggroRadius + 0.5;
}
if (this.moveSpeed < 0) {
this.moveSpeed = 0;
}
if (this.idleMoveSpeed < 0) {
this.idleMoveSpeed = 0;
}
}
start() {
// 実行時にプレイヤーリストが空の場合は警告を出す(必須ではないが、気付きやすくする)
if (!this.players || this.players.length === 0) {
console.warn('[AggroSystem] players が設定されていません。追跡対象がいないため、ステートは常に Idle になります。', this.node);
}
}
update(deltaTime: number) {
// エディタでプレビュー中(再生ボタンが押されていない)なら何もしない
if (!director.isPaused() && !director.isPlaying) {
return;
}
// 毎フレーム、最も近いターゲットと距離を更新
const nearestInfo = this._findNearestPlayer();
const nearestPlayer = nearestInfo.node;
const nearestDistance = nearestInfo.distance;
// ステート更新ロジック
this._updateState(nearestPlayer, nearestDistance);
// ステートに応じて移動処理
switch (this._state) {
case AggroState.Chasing:
if (this._currentTarget) {
this._moveTowardsTarget(this._currentTarget, this.moveSpeed, deltaTime);
}
break;
case AggroState.Idle:
default:
if (this.returnToOriginOnIdle) {
this._moveBackToOrigin(this.idleMoveSpeed, deltaTime);
}
break;
}
}
/**
* 最も近いプレイヤー候補と、その距離を返す。
* プレイヤーが 1 体もいない場合は { node: null, distance: Infinity } を返す。
*/
private _findNearestPlayer(): { node: Node | null; distance: number } {
if (!this.players || this.players.length === 0) {
return { node: null, distance: Number.POSITIVE_INFINITY };
}
let nearest: Node | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
const selfPos = this.node.worldPosition;
for (const p of this.players) {
if (!p || !p.isValid) {
continue;
}
p.getWorldPosition(this._tempVec);
const dist = Vec3.distance(selfPos, this._tempVec);
if (dist < nearestDistance) {
nearestDistance = dist;
nearest = p;
}
}
return { node: nearest, distance: nearestDistance };
}
/**
* 現在の距離情報に基づいてステートを更新する。
* ヒステリシス(aggroRadius / loseAggroRadius)を利用してガタつきを防ぐ。
*/
private _updateState(nearestPlayer: Node | null, nearestDistance: number) {
if (!nearestPlayer) {
// ターゲット候補がいない場合は Idle に戻す
if (this._state !== AggroState.Idle) {
this._changeState(AggroState.Idle, null);
}
return;
}
switch (this._state) {
case AggroState.Idle:
// Idle 中に、プレイヤーが索敵範囲内に入ったら Chasing へ
if (nearestDistance <= this.aggroRadius) {
this._changeState(AggroState.Chasing, nearestPlayer);
}
break;
case AggroState.Chasing:
// Chasing 中に、ターゲットが敵対解除範囲の外へ出たら Idle へ
if (nearestDistance >= this.loseAggroRadius) {
this._changeState(AggroState.Idle, null);
} else {
// まだ範囲内なら、最も近いプレイヤーをターゲットとして更新
this._currentTarget = nearestPlayer;
}
break;
}
}
/**
* ステート変更時の処理を一箇所にまとめる。
*/
private _changeState(newState: AggroState, newTarget: Node | null) {
if (this._state === newState && this._currentTarget === newTarget) {
return;
}
const prevState = this._state;
this._state = newState;
this._currentTarget = newTarget;
// デバッグログ(必要に応じてコメントアウト可)
// console.log(`[AggroSystem] State changed: ${AggroState[prevState]} -> ${AggroState[newState]}`, this.node);
}
/**
* 指定したターゲットノードの方向へ移動する。
*/
private _moveTowardsTarget(target: Node, speed: number, deltaTime: number) {
if (!target || !target.isValid || speed <= 0) {
return;
}
const selfPos = this.node.worldPosition;
const targetPos = target.worldPosition;
const dir = new Vec3(
targetPos.x - selfPos.x,
targetPos.y - selfPos.y,
targetPos.z - selfPos.z
);
if (!this.enableVerticalMovement) {
// 垂直方向を無視して X 軸のみ追跡
dir.y = 0;
}
const len = dir.length();
if (len <= 0.0001) {
return;
}
dir.normalize();
const moveDist = speed * deltaTime;
// 移動距離がターゲットとの距離を超えないようにクランプ
const actualMove = Math.min(moveDist, len);
const newPos = new Vec3(
selfPos.x + dir.x * actualMove,
selfPos.y + dir.y * actualMove,
selfPos.z + dir.z * actualMove
);
this.node.setWorldPosition(newPos);
}
/**
* 初期位置へ戻る処理。
*/
private _moveBackToOrigin(speed: number, deltaTime: number) {
if (speed <= 0) {
return;
}
const selfPos = this.node.worldPosition;
const origin = this._originPosition;
const dir = new Vec3(
origin.x - selfPos.x,
origin.y - selfPos.y,
origin.z - selfPos.z
);
if (!this.enableVerticalMovement) {
dir.y = 0;
}
const len = dir.length();
if (len <= 0.0001) {
// ほぼ初期位置にいる
return;
}
dir.normalize();
const moveDist = speed * deltaTime;
const actualMove = Math.min(moveDist, len);
const newPos = new Vec3(
selfPos.x + dir.x * actualMove,
selfPos.y + dir.y * actualMove,
selfPos.z + dir.z * actualMove
);
this.node.setWorldPosition(newPos);
}
/**
* シーンビュー上に索敵範囲を可視化するギズモ描画。
* Cocos Creator 3.8 では、Gizmo API が内部用のため、
* 実プロジェクトでは Editor 拡張か、単純なデバッグ用コンポーネントに分離しても良いです。
* ここでは「エディタ上での可視化のイメージ」として簡易実装します。
*/
// 注意: 実際の 3.8 ではカスタムギズモは Editor 拡張として定義するのが正式ですが、
// ここでは executeInEditMode と onDrawGizmos ライクなメソッドを例示的に記述します。
// 実行環境により無視される場合があります。
// @ts-ignore
onDrawGizmos(gizmo: Gizmo) {
if (!this.debugDrawGizmos) {
return;
}
const pos = this.node.worldPosition;
const center = new geometry.Vec3(pos.x, pos.y, pos.z);
// 索敵範囲
gizmo.color(this.debugColorAggro);
gizmo.drawWireSphere(center, this.aggroRadius);
// 敵対解除範囲
gizmo.color(this.debugColorLose);
gizmo.drawWireSphere(center, this.loseAggroRadius);
}
}
コードのポイント解説
- onLoad()
– 敵ノードの初期位置(worldPosition)を_originPositionに保存します。
–loseAggroRadius < aggroRadiusのような不正値を補正し、防御的に初期化しています。 - start()
–playersが空の場合に警告を出し、設定漏れに気付けるようにしています。 - update(deltaTime)
– 毎フレーム、_findNearestPlayer()で最も近いプレイヤーと距離を取得。
–_updateState()で Idle / Chasing のステート遷移を行い、
その結果に応じて_moveTowardsTarget()または_moveBackToOrigin()を呼び分けます。 - _findNearestPlayer()
– Inspector で設定したplayersの中から最も近いノードを探索し、距離とともに返します。 - _updateState()
–aggroRadiusとloseAggroRadiusの 2 つの閾値を使ってヒステリシスを実装。
– これにより、プレイヤーが境界付近にいるときのステートのガタつきを防ぎます。 - _moveTowardsTarget()
– ターゲットへの方向ベクトルを正規化し、moveSpeed * deltaTimeだけ移動させます。
–enableVerticalMovementが OFF の場合は Y 成分を 0 にして、X 軸のみ移動させるようにしています。 - _moveBackToOrigin()
– 追跡終了後に初期位置へ戻る処理です。
– こちらもidleMoveSpeedを使ってなめらかに移動します。 - onDrawGizmos()(エディタ用)
– シーンビュー上にaggroRadiusとloseAggroRadiusの円(球)を描画します。
– 実際の 3.8 プロジェクトでは Editor 拡張としてギズモを定義するのが正式ですが、ここでは概念的な例として示しています。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタの Assets パネルで右クリックします。
- Create → TypeScript を選択します。
- 新規スクリプトに
AggroSystem.tsという名前を付けます。 - 作成された
AggroSystem.tsをダブルクリックしてエディタ(VSCode 等)で開き、
既存のテンプレートコードをすべて削除して、前述のコードをそのまま貼り付けて保存します。
2. テスト用シーンの準備(2D想定)
ここでは 2D 横スクロール風のシンプルなテスト環境を例にします。
- Canvas 配下、またはルート直下にテスト用ノードを作成します。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を
Playerに変更します。 - 同様に Create → 2D Object → Sprite で
Enemyノードを作成します。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を
PlayerとEnemyに適当な SpriteFrame(画像)を割り当てて、見分けがつくようにします。- シーン上で
Enemyを少し左側、Playerを右側に配置しておきます。
3. AggroSystem コンポーネントのアタッチ
- Hierarchy で
Enemyノードを選択します。 - Inspector の一番下にある Add Component ボタンをクリックします。
- Custom → AggroSystem を選択して追加します。
4. プロパティの設定
Enemy ノードを選択した状態で、Inspector に表示される AggroSystem の各プロパティを設定します。
- Players
– 要素数を 1 に設定し、要素 0 にPlayerノードをドラッグ&ドロップします。 - Aggro Radius(索敵範囲)
– 例: 5 に設定します。 - Lose Aggro Radius(敵対解除範囲)
– 例: 6 に設定します(Aggro Radius より少し大きく)。 - Move Speed(追跡速度)
– 例: 3 に設定します。 - Return To Origin On Idle
– ON(チェック)にしておくと、追跡をやめたあとに元の位置へ戻ります。 - Idle Move Speed
– 例: 2 に設定します。 - Enable Vertical Movement
– 2D 横スクロール想定なら OFF(チェックを外す)にして、X 軸だけ追跡させると分かりやすいです。 - Debug Draw Gizmos
– ON にしておくと、Scene ビュー上に緑(索敵)と赤(解除)の円が表示される想定です。
5. 動作確認
- 上部の Play ボタンをクリックしてゲームを再生します。
- エディタの Game ビューで、
Playerをキーボード操作で動かせるようにしていない場合は、まずは単純に「最初から索敵範囲内にいるかどうか」で挙動を確認します。- Scene ビューで
PlayerノードをEnemyに近づけておき、再生すると、EnemyがPlayerに向かって移動し始めます。 - Scene ビュー上で再生中に
Playerをドラッグして遠くへ動かすと、Enemyが一定距離(Lose Aggro Radius)を超えたところで追跡をやめ、初期位置へ戻ります。
- Scene ビューで
- より本格的にテストしたい場合は、別途
PlayerControllerのようなスクリプトでPlayerをキーボード移動させ、
プレイヤーが自分で操作して索敵範囲に出入りしたときの挙動を確認してみてください(AggroSystem 自体は外部スクリプトに依存しません)。
6. 複数プレイヤーでのテスト(任意)
マルチプレイ風のテストも簡単にできます。
Playerノードを複製してPlayer2を作成します。- Inspector の AggroSystem → Players に、要素数 2 として
Player,Player2を登録します。 - Scene 上で
PlayerとPlayer2を好きな位置に配置し、ゲームを再生します。 - 敵は常に「より近い方のプレイヤー」をターゲットとして追跡し、距離関係が変わるとターゲットも切り替わります。
まとめ
この AggroSystem コンポーネントは、
- 他のカスタムスクリプトやシングルトンに依存せず
- 敵ノードにアタッチしてプレイヤー候補を設定するだけで
- 「プレイヤーが範囲に入ったら追跡し、離れたら待機に戻る」ステート管理と移動
を実現できる、汎用的な敵対管理コンポーネントです。
応用としては、
- ステートに応じてアニメーション(Idle / Run)を切り替える
- 攻撃可能距離を別途設けて「Attack」ステートを追加する
- NavMesh や物理ベースの移動に差し替える(現在は Transform 直接移動)
- ターゲットをプレイヤーだけでなく、特定タグを持つオブジェクトに拡張する
など、さまざまな発展が可能です。
まずはこのシンプルな AggroSystem をベースに、自分のゲームに合わせて挙動やステートを拡張してみてください。
コンポーネント単体で完結する設計なので、どのプロジェクトにも簡単に持ち込んで再利用できます。




