Godot 4で敵AIを書くとき、つい「EnemyBase」を継承した巨大スクリプトを作ってしまいがちですよね。
そこに「リーダー」「雑魚」「ボス」…とバリエーションを増やしていくと、EnemyLeader.gd / EnemyMinion.gd / EnemyBoss.gd みたいに継承ツリーがどんどん伸びていきます。
さらに、「この敵は自分では攻撃せず、周りの雑魚に攻撃命令だけ出したい」といった行動を追加しようとすると、
- 攻撃ロジックをリーダー用クラスにコピペして条件分岐
- ミニオンとの参照関係を
get_node()でベタ書き - シーン階層が「Leader → Minions…」みたいにガチガチに固定される
といった「継承+深いノード階層」地獄にハマりがちです。
そこで今回は、どの敵ノードにもポン付けできる「リーダーAI」コンポーネントを作ってみましょう。
自分では攻撃せず、周囲の Minion に「攻撃しろ」と命令を出すだけの PackLeader コンポーネントです。
【Godot 4】群れを操る司令塔AI!「PackLeader」コンポーネント
このコンポーネントは、ざっくりいうと:
- 自分の周囲の Minion を検出する(指定グループやエリア内)
- ターゲット(プレイヤーなど)を監視する
- 攻撃タイミングになったら、Minion に「攻撃しろ」とシグナルで命令する
- 自分自身は攻撃ロジックを一切持たない
Minion 側は「命令を聞く」コンポーネントを持っていればOK、リーダーとミニオンの関係は グループ名 や エリア でゆるく紐づけます。
つまり、シーン階層をガチガチに固定せず、「合成(Composition)」だけで群れAIを組めるようにするのが狙いです。
PackLeader.gd – フルコード
extends Node
class_name PackLeader
##
## PackLeader コンポーネント
## - 自分は攻撃せず、周囲の Minion に「攻撃しろ」と命令を出す
## - Minion は「attack_order(target)」メソッド、またはシグナルを受け取る想定
##
## 想定利用ノード:
## - 親ノードは敵の本体 (CharacterBody2D / 3D 等)
## - このコンポーネントは子ノードとしてアタッチするだけ
##
## --- 設定パラメータ(エディタから変更可能) ---
@export_group("基本設定")
## リーダーが命令対象とする Minion が所属するグループ名
@export var minion_group_name: StringName = &"minion"
## 攻撃対象(プレイヤーなど)のノードパス
## - 空の場合は、毎フレーム自動で「target_group_name」から最も近いターゲットを探す
@export var target_node_path: NodePath
## 自動探索するターゲットのグループ名
## - target_node_path が空のときに使用
@export var target_group_name: StringName = &"player"
## リーダーが命令を出す最大距離(ターゲットとの距離)
@export var command_range: float = 400.0
## 命令を出す間隔(秒)
@export var command_interval: float = 1.0
@export_group("Minion 検出設定")
## Minion を検索する半径
@export var minion_search_radius: float = 500.0
## Minion を検索する際に使うワールドレイヤー(2D/3Dで適宜変更)
## 2D: PhysicsPointQueryParameters2D、3D: PhysicsPointQueryParameters3D 等に合わせて使う
@export_flags_2d_physics var minion_collision_mask: int = 1
## Minion が攻撃可能とみなす最大距離(Minion とターゲット間)
@export var minion_attack_distance: float = 350.0
@export_group("デバッグ表示")
## デバッグ用に、リーダーのコマンド範囲を可視化するか
@export var debug_draw: bool = true
## デバッグ描画の色
@export var debug_color: Color = Color(0.3, 0.8, 1.0, 0.5)
## --- 内部状態 ---
## 次に命令を出せる時間
var _next_command_time: float = 0.0
## キャッシュ: 親ノード(リーダー本体)
var _owner_body: Node2D
## キャッシュ: 現在のターゲット
var _current_target: Node2D
func _ready() -> void:
## 親ノードをキャッシュ
_owner_body = get_parent() as Node2D
if _owner_body == null:
push_warning("PackLeader は Node2D の子として使うことを想定しています。親が Node2D ではありません。")
## 最初の命令タイミングを現在時刻に
_next_command_time = Time.get_ticks_msec() / 1000.0
func _process(delta: float) -> void:
if _owner_body == null:
return
_update_target()
if _current_target == null:
return
var now := Time.get_ticks_msec() / 1000.0
## 命令間隔チェック
if now < _next_command_time:
return
## ターゲットとの距離がコマンド範囲内か確認
var dist_to_target := _owner_body.global_position.distance_to(_current_target.global_position)
if dist_to_target > command_range:
return
## Minion を集めて命令を出す
var minions := _find_nearby_minions()
_command_minions(minions, _current_target)
## 次の命令タイミングを更新
_next_command_time = now + command_interval
## デバッグ表示更新
if debug_draw:
queue_redraw()
func _draw() -> void:
## コマンド範囲の可視化
if not debug_draw or _owner_body == null:
return
draw_set_transform(-global_position) # ローカル座標に戻す
draw_circle(_owner_body.global_position, command_range, debug_color)
## --- ターゲット関連 ---
func _update_target() -> void:
## 直接パス指定されている場合はそれを使用
if target_node_path != NodePath():
var node := get_node_or_null(target_node_path)
if node and node is Node2D:
_current_target = node
return
else:
_current_target = null
return
## グループから最も近いターゲットを探す
var candidates := get_tree().get_nodes_in_group(target_group_name)
if candidates.is_empty():
_current_target = null
return
var closest: Node2D = null
var closest_dist := INF
for c in candidates:
if not (c is Node2D):
continue
var d := _owner_body.global_position.distance_to(c.global_position)
if d < closest_dist:
closest_dist = d
closest = c
_current_target = closest
## --- Minion 検出と命令 ---
func _find_nearby_minions() -> Array:
## シンプルにグループから取得し、距離でフィルタする方式
## 物理クエリにしたい場合は PhysicsDirectSpaceState2D を使うように改造してもOK
var result: Array = []
var all_minions := get_tree().get_nodes_in_group(minion_group_name)
if _owner_body == null:
return result
for m in all_minions:
if not (m is Node2D):
continue
var d := _owner_body.global_position.distance_to(m.global_position)
if d <= minion_search_radius:
result.append(m)
return result
func _command_minions(minions: Array, target: Node2D) -> void:
if minions.is_empty():
return
for m in minions:
## Minion 側に attack_order(target) があれば呼ぶ
if "attack_order" in m:
## Minion とターゲットの距離チェック(遠すぎる Minion は命令しない)
var dist_to_target := (m as Node2D).global_position.distance_to(target.global_position)
if dist_to_target <= minion_attack_distance:
m.attack_order(target)
else:
## attack_order が無い場合は、シグナルで通知する設計にしてもOK
## ここでは警告だけ出しておく
push_warning("Minion '%s' に attack_order(target) が定義されていません。" % m.name)
## --- 補助: 手動で命令をトリガーしたい場合用 ---
func force_command() -> void:
## 外部から「今すぐ命令しろ」と呼べる API
if _owner_body == null:
return
_update_target()
if _current_target == null:
return
var minions := _find_nearby_minions()
_command_minions(minions, _current_target)
使い方の手順
ここでは 2D を例に説明しますが、3D でも基本の考え方は同じです。
手順① Minion 側のコンポーネント(またはメソッド)を用意する
PackLeader は Minion に対して attack_order(target) を呼び出します。
なので、Minion 側にはこのメソッドを用意しておきましょう。コンポーネント指向で書くなら、例えばこんな感じです:
extends Node
class_name MinionCommander
##
## Minion が PackLeader からの命令を受け取るためのコンポーネント
##
@export var move_speed: float = 120.0
var _body: CharacterBody2D
var _current_target: Node2D
func _ready() -> void:
_body = get_parent() as CharacterBody2D
if _body == null:
push_warning("MinionCommander は CharacterBody2D の子として使うことを想定しています。")
func _physics_process(delta: float) -> void:
if _body == null or _current_target == null:
return
var dir := (_current_target.global_position - _body.global_position).normalized()
_body.velocity = dir * move_speed
_body.move_and_slide()
func attack_order(target: Node2D) -> void:
## PackLeader からの「攻撃しろ」命令
_current_target = target
もちろん、既存の Minion スクリプトに func attack_order(target): を直接足してもOKです。
手順② Minion をグループに入れる
PackLeader は グループ を使って Minion を見つけます。
- エディタで Minion シーンを開く
- ルート(例:
Minion (CharacterBody2D))を選択 - インスペクタの「ノード」タブ → 「グループ」 →
minionというグループを追加
PackLeader の minion_group_name を変更すれば、別のグループ名でも構いません。
手順③ リーダー(PackLeader)を敵本体にアタッチ
リーダー用の敵シーンを用意して、その子として PackLeader を追加します。
例: プレイヤーに突撃させる Minion を束ねるリーダー
LeaderEnemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── PackLeader (Node)
Minion 側の構成例:
Minion (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── MinionCommander (Node)
この構成のポイントは、LeaderEnemy と Minion の親子関係は不要なところです。
単に「同じシーン内のどこかにいる」「グループに所属している」だけで、PackLeader は Minion を見つけて命令を出します。
手順④ PackLeader のパラメータを調整する
LeaderEnemy の子にある PackLeader を選択し、インスペクタから以下を設定します:
minion_group_name… Minion のグループ名(例:minion)target_node_path… 直接ターゲットを指定したいならここにプレイヤーをドラッグ&ドロップtarget_group_name… 自動探索したいターゲットのグループ(例:player)command_range… リーダーが命令を出す距離(プレイヤーとリーダーの距離)command_interval… 命令のインターバルminion_search_radius… Minion を巻き込む半径minion_attack_distance… Minion とターゲットの距離がこの範囲なら命令する
プレイヤーの構成例も載せておきます:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── PlayerController (Node) ※任意
プレイヤーを player グループに入れておけば、target_group_name = "player" で自動検出されます。
メリットと応用
この PackLeader コンポーネントを使うことで、敵AI周りがかなりスッキリします。
- 継承ツリーを増やさずに「リーダーAI」を付け外しできる
Leader 用のクラスを新たに継承して作る必要はありません。既存の敵ノードに PackLeader をポン付けするだけで、群れを率いる挙動を追加できます。 - Minion 側は「命令を聞く」責務だけ
Minion は「どう動くか」だけに集中し、「誰から命令を受けるか」を知る必要がありません。PackLeader からattack_order()を呼ばれるだけの、疎結合な設計になります。 - シーン構造に縛られない
Leader と Minion を親子にする必要はなく、同じレベルシーンの中にバラバラに置いても動きます。レベルデザイン時に「この部屋だけ Minion を増やす」「このリーダーは2グループを束ねる」など、自由度が高くなります。 - 別ゲームでも再利用しやすい
PackLeader 自体は「Minion グループに向けて命令を出す」だけの汎用コンポーネントなので、アクションでもタワーディフェンスでも、別プロジェクトにそのまま持っていきやすいです。
「継承より合成」で考えると、
- 「リーダーであること」 = PackLeader コンポーネント
- 「命令を聞くミニオンであること」 = MinionCommander コンポーネント
- 「移動できるキャラであること」 = CharacterBody2D + Movement コンポーネント
といった具合に、役割ごとに分割して組み合わせるイメージになりますね。
改造案:Minion に「攻撃優先度」を付ける
例えば、「HP が多い Minion から優先的に命令したい」「遠距離 Minion だけ命令したい」といった要望が出てきたら、_command_minions() を少し改造するだけで対応できます。
func _command_minions(minions: Array, target: Node2D) -> void:
if minions.is_empty():
return
# 例: Minion 側に priority プロパティがあれば、それでソートしてから命令
minions.sort_custom(func(a, b):
var pa := ("priority" in a) ? a.priority : 0
var pb := ("priority" in b) ? b.priority : 0
return pa > pb # 大きいほど優先
)
for m in minions:
if "attack_order" in m:
var dist_to_target := (m as Node2D).global_position.distance_to(target.global_position)
if dist_to_target <= minion_attack_distance:
m.attack_order(target)
この程度の変更で「タンク Minion を優先して前に出す」「遠距離ユニットは距離が近すぎると命令しない」など、けっこう高度な群れAIに発展させられます。
大事なのは、リーダーとミニオンの責務をきれいに分けておくことですね。
ぜひ、自分のプロジェクトに合わせて PackLeader を育ててみてください。
