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 を見つけます。

  1. エディタで Minion シーンを開く
  2. ルート(例: Minion (CharacterBody2D))を選択
  3. インスペクタの「ノード」タブ → 「グループ」 → 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 を育ててみてください。