【Cocos Creator 3.8】PackLeader(リーダーAI)の実装:アタッチするだけで周囲のMinionに攻撃命令をブロードキャストする汎用スクリプト
このガイドでは、敵集団の「リーダー」ノードにアタッチするだけで、周囲の雑魚敵(Minion)へ「攻撃しろ」という命令を定期的に送る PackLeader コンポーネントを実装します。
PackLeader 自身は一切攻撃せず、あくまで「命令を出すAI」です。Minion 側には「命令を受け取るための汎用インターフェース」を用意し、タグ名や距離、命令間隔などはすべてインスペクタから調整できるようにします。
コンポーネントの設計方針
1. 機能要件の整理
- PackLeader は 自分では攻撃しない。
- 一定間隔で「攻撃しろ」という命令を周囲の Minion に送る。
- Minion は 特定のタグ や ノード名の一部 などで識別できるようにする。
- 命令は カスタムイベント名 でブロードキャストし、Minion 側はそのイベントを受け取るだけでよい。
- PackLeader は 外部のカスタムスクリプトに依存しない。Minion 側がどんな実装であっても、イベント名とパラメータ仕様さえ合わせれば動く。
- 攻撃対象(ターゲット)がいる前提だが、ターゲット自体は Node 参照を Inspector で指定 する。
2. 外部依存をなくすためのアプローチ
- Minion への命令は
Node.emit/Node.dispatchEventではなく、グローバル EventTarget を使わず、直接 Node.emit で送る。 - Minion 側は、任意のスクリプト内で
this.node.on('attack-command', ...)のように受信するだけでよい。 - 「MinionManager」や「GameManager」などのシングルトンは一切使わない。
- Minion の探索は、PackLeader の親ノード以下 から行う。これにより、シーン全体に依存せず、グループ単位で完結する。
3. インスペクタで設定可能なプロパティ設計
PackLeader に用意する @property を一覧で整理します。
- minionTag: string
- 説明: Minion を識別するためのタグ文字列。
- 使い方: Minion ノードの
nameにこの文字列が含まれていれば「Minion」とみなす。 - 例:
"Minion"とすると、"EnemyMinion_01"などがヒットする。
- searchRadius: number
- 説明: PackLeader を中心に、この半径以内にいる Minion のみを命令対象とする。
- 単位: ワールド座標系の距離(おおよそピクセル)。
- 例: 500 に設定すると、500以内の距離にいる Minion のみ命令を受け取る。
- commandInterval: number
- 説明: 攻撃命令を送る間隔(秒)。
- 例: 1.0 に設定すると、1秒ごとに攻撃命令を送る。
- commandEventName: string
- 説明: Minion に送信するイベント名。
- Minion 側は
this.node.on(commandEventName, ...)で受信する。 - 例:
"attack-command"。
- targetNode: Node | null
- 説明: Minion に「このターゲットを攻撃しろ」と伝える対象ノード。
- 例: プレイヤーキャラのノードを設定する。
- 未設定の場合: 警告ログを出しつつ、Minion には
nullを渡す。
- debugLog: boolean
- 説明: Minion 検索や命令送信のログをコンソールに出すかどうか。
- デバッグ時のみ有効にすることを推奨。
- autoStart: boolean
- 説明:
trueの場合、onLoadで自動的に命令ループを開始する。 falseの場合、外部からstartCommanding()を呼び出すまで命令は送られない。
- 説明:
Minion 側が受け取るイベントのペイロード仕様も明確にしておきます。
// Minion 側で受け取るイベントの引数イメージ
interface AttackCommandPayload {
leader: Node; // 命令を出した PackLeader のノード
target: Node | null; // 攻撃対象(未指定なら null)
}
PackLeader からは以下のように emit します。
minionNode.emit(this.commandEventName, {
leader: this.node,
target: this.targetNode,
});
TypeScriptコードの実装
以下が完成した PackLeader.ts の全コードです。
import { _decorator, Component, Node, Vec3, math, log, warn } from 'cc';
const { ccclass, property } = _decorator;
interface AttackCommandPayload {
leader: Node;
target: Node | null;
}
@ccclass('PackLeader')
export class PackLeader extends Component {
@property({
tooltip: 'Minion ノードを識別するためのキーワード。\n' +
'ノード名にこの文字列が含まれていれば Minion とみなします。\n' +
'例: "Minion" とすると "EnemyMinion_01" などがヒットします。',
})
public minionTag: string = 'Minion';
@property({
tooltip: 'PackLeader を中心に、この半径以内にいる Minion のみを命令対象とします(ワールド座標系)。',
min: 0,
})
public searchRadius: number = 500;
@property({
tooltip: '攻撃命令を送る間隔(秒)。\n' +
'0 以下にすると毎フレーム命令を送りますが、パフォーマンスに注意してください。',
min: 0,
})
public commandInterval: number = 1.0;
@property({
tooltip: 'Minion に送信するイベント名。\n' +
'Minion 側は this.node.on(この名前, (payload: AttackCommandPayload) => {...}) で受信します。',
})
public commandEventName: string = 'attack-command';
@property({
tooltip: 'Minion に「このターゲットを攻撃しろ」と伝える対象ノード。\n' +
'通常はプレイヤーキャラのノードを設定します。\n' +
'未設定の場合、警告を出しつつ target: null を送信します。',
})
public targetNode: Node | null = null;
@property({
tooltip: 'true の場合、Minion 検索や命令送信の詳細ログをコンソールに出力します。',
})
public debugLog: boolean = false;
@property({
tooltip: 'true の場合、コンポーネント有効化時に自動で命令ループを開始します。\n' +
'false の場合は、外部から startCommanding() を呼び出すまで命令を送りません。',
})
public autoStart: boolean = true;
// 内部状態
private _elapsed: number = 0;
private _isCommanding: boolean = false;
onLoad() {
// 防御的実装: 最低限の設定チェック
if (!this.minionTag || this.minionTag.trim().length === 0) {
warn('[PackLeader] minionTag が空です。Minion を検出できません。');
}
if (!this.commandEventName || this.commandEventName.trim().length === 0) {
warn('[PackLeader] commandEventName が空です。Minion はイベントを受信できません。');
}
if (!this.targetNode) {
warn('[PackLeader] targetNode が設定されていません。Minion には target: null が渡されます。');
}
if (this.autoStart) {
this.startCommanding();
}
}
onEnable() {
// 再有効化時に自動再開したい場合はここで制御可能
if (this.autoStart && !this._isCommanding) {
this.startCommanding();
}
}
onDisable() {
// 無効化中は命令を止める
this.stopCommanding();
}
/**
* 命令ループを開始します。
* 外部スクリプトからも呼び出せるように public にしてあります。
*/
public startCommanding() {
this._isCommanding = true;
this._elapsed = 0;
if (this.debugLog) {
log('[PackLeader] Commanding started.');
}
}
/**
* 命令ループを停止します。
*/
public stopCommanding() {
this._isCommanding = false;
this._elapsed = 0;
if (this.debugLog) {
log('[PackLeader] Commanding stopped.');
}
}
update(deltaTime: number) {
if (!this._isCommanding) {
return;
}
// commandInterval が 0 以下なら毎フレーム命令
if (this.commandInterval > 0) {
this._elapsed += deltaTime;
if (this._elapsed < this.commandInterval) {
return;
}
this._elapsed = 0;
}
this._broadcastAttackCommand();
}
/**
* 親ノード以下から Minion を探し、攻撃命令を送信します。
*/
private _broadcastAttackCommand() {
const leaderNode = this.node;
const leaderWorldPos = new Vec3();
leaderNode.getWorldPosition(leaderWorldPos);
const rootForSearch = leaderNode.parent;
if (!rootForSearch) {
warn('[PackLeader] 親ノードが存在しないため、Minion の探索ができません。');
return;
}
const minions: Node[] = [];
this._collectMinionsRecursively(rootForSearch, minions);
if (minions.length === 0) {
if (this.debugLog) {
log('[PackLeader] 対象となる Minion が見つかりませんでした。');
}
return;
}
const payload: AttackCommandPayload = {
leader: leaderNode,
target: this.targetNode,
};
let sentCount = 0;
for (const m of minions) {
const minionWorldPos = new Vec3();
m.getWorldPosition(minionWorldPos);
const distance = Vec3.distance(leaderWorldPos, minionWorldPos);
if (distance <= this.searchRadius) {
m.emit(this.commandEventName, payload);
sentCount++;
if (this.debugLog) {
log(`[PackLeader] 命令送信: Minion="${m.name}", distance=${distance.toFixed(2)}`);
}
}
}
if (this.debugLog) {
log(`[PackLeader] ブロードキャスト完了: 対象Minion数=${minions.length}, 実際に送信した数=${sentCount}`);
}
}
/**
* 親ノード以下を再帰的に探索し、minionTag を含む名前のノードを Minion として収集します。
*/
private _collectMinionsRecursively(current: Node, outMinions: Node[]) {
for (const child of current.children) {
if (this._isMinionNode(child)) {
outMinions.push(child);
}
// 再帰的に探索
if (child.children.length > 0) {
this._collectMinionsRecursively(child, outMinions);
}
}
}
/**
* ノード名に minionTag が含まれていれば Minion とみなす簡易判定。
*/
private _isMinionNode(node: Node): boolean {
if (!this.minionTag) {
return false;
}
return node.name.indexOf(this.minionTag) !== -1;
}
}
ライフサイクルメソッドのポイント解説
- onLoad
- インスペクタで設定された値の簡易バリデーションを行い、問題があれば
warnを出す。 autoStartがtrueの場合、startCommanding()を呼び出して命令ループを開始。
- インスペクタで設定された値の簡易バリデーションを行い、問題があれば
- onEnable / onDisable
- コンポーネントの有効化・無効化に合わせて命令ループを開始・停止。
- update
_isCommandingがtrueのときだけ動作。commandIntervalに応じて_broadcastAttackCommand()を呼び出す。
- _broadcastAttackCommand
- 親ノード以下から Minion を収集し、
searchRadius以内にいるものへイベントを送信。 - Minion 側への依存は イベント名とペイロード形式のみ に限定。
- 親ノード以下から Minion を収集し、
使用手順と動作確認
1. PackLeader.ts を作成する
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を PackLeader.ts に変更します。
- 作成された
PackLeader.tsをダブルクリックして開き、既存の中身をすべて削除して、前述のコードを丸ごと貼り付けます。 - 保存します(Ctrl+S / Cmd+S)。
2. テスト用のシーン構成を作る
ここでは、簡単なテストシーンを例に説明します。
- Hierarchy パネルで右クリック → Create → Empty Node を選択し、名前を EnemyGroup に変更します。
- このノード配下に PackLeader と Minion をまとめて配置します。
- EnemyGroup を選択した状態で、右クリック → Create → 2D Object → Sprite を選択し、名前を PackLeader に変更します。
- この Sprite ノードがリーダーになります(見た目は何でもOK)。
- 同様に、EnemyGroup を右クリック → Create → 2D Object → Sprite を複数作成し、名前を以下のように変更します。
EnemyMinion_01EnemyMinion_02EnemyMinion_03
これらが Minion 役になります。名前に
Minionが含まれていることがポイントです。 - プレイヤー役のターゲットノードも作成します。
- Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、名前を Player に変更します。
- 位置を少し離れた場所に移動しておきます(例: X=300, Y=0)。
3. PackLeader コンポーネントをアタッチする
- Hierarchy で PackLeader ノードを選択します。
- Inspector の一番下にある Add Component ボタンをクリックします。
- Custom Component → PackLeader を選択して追加します。
- Inspector に表示された PackLeader のプロパティを以下のように設定します。
- Minion Tag:
Minion - Search Radius:
500(またはシーンのスケールに合わせて調整) - Command Interval:
1.0 - Command Event Name:
attack-command - Target Node: Hierarchy から Player ノードをドラッグ&ドロップ
- Debug Log:
true(テスト中は有効にすると分かりやすい) - Auto Start:
true
- Minion Tag:
4. Minion 側の簡易受信スクリプトを作る(任意だが動作確認に推奨)
PackLeader 自体は他のカスタムスクリプトに依存しませんが、動作確認のために Minion 側でイベントを受け取ってログを出すだけの簡単なスクリプトを用意すると分かりやすいです。
- Assets パネルで右クリック → Create → TypeScript を選択し、ファイル名を MinionListener.ts に変更します。
- 以下のコードを貼り付けます。
import { _decorator, Component, Node, log } from 'cc';
const { ccclass, property } = _decorator;
interface AttackCommandPayload {
leader: Node;
target: Node | null;
}
@ccclass('MinionListener')
export class MinionListener extends Component {
@property
public listenEventName: string = 'attack-command';
onEnable() {
this.node.on(this.listenEventName, this.onAttackCommand, this);
}
onDisable() {
this.node.off(this.listenEventName, this.onAttackCommand, this);
}
private onAttackCommand(payload: AttackCommandPayload) {
const leaderName = payload.leader?.name ?? 'UnknownLeader';
const targetName = payload.target?.name ?? 'NoTarget';
log(`[MinionListener] ${this.node.name} が攻撃命令を受信: leader=${leaderName}, target=${targetName}`);
}
}
このスクリプトは PackLeader に依存していないので、要件である「PackLeader の完全独立性」を損ないません。
- Hierarchy で EnemyMinion_01, EnemyMinion_02, EnemyMinion_03 を選択します。
- Inspector で Add Component → Custom Component → MinionListener を追加します。
- Listen Event Name が
attack-commandになっていることを確認します。
5. ゲームを再生して動作を確認する
- エディタ右上の ▶(Play) ボタンを押してゲームを実行します。
- Console パネルを開いておきます(Window → Console)。
- 再生後、1秒ごとに以下のようなログが出力されていれば成功です。
[PackLeader] 命令送信: Minion="EnemyMinion_01", distance=120.35[MinionListener] EnemyMinion_01 が攻撃命令を受信: leader=PackLeader, target=Player
- Minion の位置を PackLeader から遠ざけて、searchRadius の外に出してみてください。
- 半径の外に出た Minion には、命令が送られなくなることを確認できます。
- PackLeader ノードの Inspector で Debug Log を
falseにすると、ログ出力を止められます。
まとめ
- PackLeader は、単体スクリプトで完結する「リーダーAI」コンポーネントです。
- Minion 側は「イベントを受け取るだけ」でよく、GameManager やシングルトンに依存しないシンプルな設計になっています。
- インスペクタから
- Minion の識別方法(
minionTag) - 有効範囲(
searchRadius) - 命令頻度(
commandInterval) - イベント名(
commandEventName) - ターゲット(
targetNode)
を柔軟に変えられるため、異なるステージや敵編成でも PackLeader をアタッチして値を変えるだけで再利用できます。
- Minion の識別方法(
- 応用例として:
- 攻撃命令だけでなく、「退却」「集結」「待機」など、イベント名を変えることで多様な行動を指示するリーダーAI。
- 敵だけでなく、味方ユニットの隊長として使い、プレイヤーの入力に応じて部下を動かす RTS 的な制御。
- Minion 側で受信した
AttackCommandPayloadを解析し、ターゲットへのナビゲーションや攻撃アニメーションを開始する複雑な AI への拡張。
このように、イベントベース&インスペクタ駆動の設計にしておくことで、PackLeader コンポーネント単体で多くのシーンに再利用でき、ゲーム全体の設計もシンプルに保てます。必要に応じて、Minion 側の挙動だけ差し替えることで、さまざまな「群れAI」「隊列AI」を構築してみてください。




