敵AIを書いていると、つい「敵ごとにスクリプトを継承して増殖させる」パターンにハマりがちですよね。
例: BaseEnemy.gd を継承した Goblin.gd / Orc.gd / Slime.gd … みたいなやつです。
その中に「仲間呼び(アグロリンク)」のロジックをベタ書きし始めると、
- 敵ごとに微妙に挙動が違って、コピペが増える
- 「この敵だけ仲間呼びオフにしたい」が面倒
- 後から仕様変更したら、全部の敵スクリプトを修正するハメになる
といった、継承ベースのつらみが一気に襲ってきます。
そこで今回は、「攻撃を受けたら、周囲にいる同じグループの敵を一斉にアクティブ化する」処理を
ひとつの独立コンポーネントとして切り出した AggroLink を用意しました。
敵側は「自分をアクティブにするメソッド」さえ持っていればOK。
あとはこのコンポーネントをペタっと貼るだけで、仲間呼びAIを後付けできるようにしていきましょう。
【Godot 4】一匹殴ると全員キレる!「AggroLink」コンポーネント
コンポーネントの考え方
このコンポーネントは、ざっくり言うと:
- 「ダメージを受けた」イベントをどこかから受け取る
- 周囲にいる「同じグループ」の敵を検索する
- その敵たちの
set_aggro(true)的なメソッドを呼ぶ
というシンプルな責務だけを持ちます。
敵の「移動ロジック」「攻撃ロジック」「アニメーション」は一切知らないし、持ちません。
あくまで「アグロ状態にするスイッチを押すマン」として分離しておくことで、
敵の種類が増えても、AggroLink 側はほぼノータッチで済むようにしてあります。
ソースコード (Full Code)
# AggroLink.gd
# 「攻撃を受けたら、周囲の仲間をアクティブにする」コンポーネント
# 任意の敵ノードにアタッチして使います。
class_name AggroLink
extends Node
## --- 設定パラメータ(エディタから調整可能) ---
@export_group("Aggro Settings")
## 仲間呼びの有効/無効。
## テスト中だけオフにしたい、特定の敵だけリンクさせたくない、などに使えます。
@export var enabled: bool = true
## 仲間を探す半径(ワールド座標ベース)。
## プレイヤーが1体の敵を攻撃したとき、この半径内にいる敵をアクティブにします。
@export var aggro_radius: float = 400.0
## 仲間呼びの対象とする「グループ名」。
## 例: "enemies", "goblins", "bandits" など。
## 同じグループに属するノードだけが仲間呼びの対象になります。
@export var ally_group_name: String = "enemies"
## 仲間に対して呼び出すメソッド名。
## ここでは「アグロ状態をオンにする」メソッドを想定。
## 例: func set_aggro(active: bool) -> void:
@export var ally_aggro_method: StringName = "set_aggro"
## 呼び出すときに true を渡すかどうか。
## false にしておけば「リンク解除」にも使えます(あまり使わないかもですが)。
@export var ally_aggro_value: bool = true
@export_group("Damage Hook")
## 「ダメージを受けた」ことを検知するためのシグナル名。
## 親ノード(敵)がこの名前のシグナルを emit してくれる前提です。
## 例: signal damaged(amount, from)
@export var damage_signal_name: StringName = "damaged"
## 親ノードにダメージシグナルが無い場合でも、
## 手動で `trigger_aggro_link()` を呼べば仲間呼びを発動できるようにしておきます。
## 例: ヒットボックス側から直接呼ぶ場合など。
## --- 内部状態 ---
var _owner_node: Node = null
var _world: Node = null
func _ready() -> void:
# このコンポーネントをアタッチしている親ノードを取得
_owner_node = get_parent()
if _owner_node == null:
push_warning("AggroLink: 親ノードが存在しません。何もできません。")
return
# ワールドのルート(シーンツリーの一番上)をキャッシュしておく
_world = get_tree().current_scene
# 親ノードにダメージシグナルがあれば、自動で接続を試みる
# 親が `signal damaged(damage, from)` のようなシグナルを持っている想定です。
if damage_signal_name != StringName("") and _owner_node.has_signal(damage_signal_name):
# 既に接続されていないかをチェックしてから接続
var sig := damage_signal_name
if not _owner_node.is_connected(sig, Callable(self, "_on_owner_damaged")):
_owner_node.connect(sig, Callable(self, "_on_owner_damaged"))
else:
# シグナルが無い場合は警告だけ出しておく(手動トリガーは使える)
push_warning(
"AggroLink: 親ノードにシグナル '%s' が見つかりません。"
% [str(damage_signal_name)]
)
## 親ノードが「ダメージを受けた」ときに呼ばれるコールバック。
## シグナルの引数は何でもよいので、可変長引数にして捨てています。
func _on_owner_damaged(_args := null) -> void:
if not enabled:
return
trigger_aggro_link()
## 外部からも呼べる「仲間呼びトリガー」。
## 例: ヒットボックス or ヘルスコンポーネントから直接呼び出す用途。
func trigger_aggro_link() -> void:
if not enabled:
return
if _owner_node == null:
return
if _world == null:
_world = get_tree().current_scene
if _world == null:
return
# 親ノードが 2D か 3D かで座標の扱いが変わるので、軽く分岐します。
var owner_global_position := _get_owner_global_position()
if owner_global_position == null:
push_warning("AggroLink: 親ノードの位置を取得できませんでした。")
return
# ワールド内の全ノードから「同じグループ」の仲間を探す
var allies: Array = _get_allies_in_radius(owner_global_position, aggro_radius)
# 見つかった仲間に対してアグロメソッドを呼び出す
for ally in allies:
# 自分自身は除外
if ally == _owner_node:
continue
if ally.has_method(ally_aggro_method):
# メソッド呼び出し。例: ally.set_aggro(true)
ally.call(ally_aggro_method, ally_aggro_value)
else:
# 開発中に気付きやすいよう、メソッドが無い場合は警告を出す
push_warning(
"AggroLink: ノード '%s' にメソッド '%s' がありません。"
% [ally.name, str(ally_aggro_method)]
)
## 親ノードの「ワールド座標」を安全に取得するヘルパー
func _get_owner_global_position() -> Variant:
# 2D系
if _owner_node is Node2D:
return (_owner_node as Node2D).global_position
if _owner_node is CharacterBody2D:
return (_owner_node as CharacterBody2D).global_position
if _owner_node is RigidBody2D:
return (_owner_node as RigidBody2D).global_position
# 3D系
if _owner_node is Node3D:
return (_owner_node as Node3D).global_position
if _owner_node is CharacterBody3D:
return (_owner_node as CharacterBody3D).global_position
if _owner_node is RigidBody3D:
return (_owner_node as RigidBody3D).global_position
# それ以外のノードには位置が無いので null
return null
## 指定半径内にいる「同じグループ」のノードを取得する。
## 2D と 3D をまとめて扱うため、distance() / distance_to() 的な処理を手書きしています。
func _get_allies_in_radius(center: Variant, radius: float) -> Array:
var result: Array = []
if ally_group_name == "":
return result
# グループに属する全ノードを取得
var candidates: Array = _world.get_tree().get_nodes_in_group(ally_group_name)
var radius_sq: float = radius * radius
for node in candidates:
# 位置を持たないノードはスキップ
var pos := _get_node_global_position(node)
if pos == null:
continue
# 2D/3D の両方を float の配列として扱い、距離の二乗を計算
var dist_sq := _distance_squared(center, pos)
if dist_sq <= radius_sq:
result.append(node)
return result
## 任意のノードのワールド座標を取得するヘルパー
func _get_node_global_position(node: Node) -> Variant:
if node is Node2D:
return (node as Node2D).global_position
if node is CharacterBody2D:
return (node as CharacterBody2D).global_position
if node is RigidBody2D:
return (node as RigidBody2D).global_position
if node is Node3D:
return (node as Node3D).global_position
if node is CharacterBody3D:
return (node as CharacterBody3D).global_position
if node is RigidBody3D:
return (node as RigidBody3D).global_position
return null
## 2D/3D ベクトルの距離の二乗を雑に計算するヘルパー。
## center / other は Vector2 か Vector3 を想定。
func _distance_squared(a: Variant, b: Variant) -> float:
# Vector2
if a is Vector2 and b is Vector2:
return (a as Vector2).distance_squared_to(b as Vector2)
# Vector3
if a is Vector3 and b is Vector3:
return (a as Vector3).distance_squared_to(b as Vector3)
# 型が混ざっていたら、とりあえず 0 を返す(ほぼ起こらない想定)
return 0.0
使い方の手順
前提:敵側に「アグロON/OFF」を切り替えるメソッドを用意する
まずは敵キャラ側に、AggroLink から呼ばれるメソッドを用意します。
ここでは 2D の CharacterBody2D を例にします。
# Enemy.gd
extends CharacterBody2D
signal damaged(amount: float, from: Node)
@export var move_speed: float = 80.0
var _is_aggro: bool = false
var _target: Node2D = null
func _physics_process(delta: float) -> void:
if _is_aggro and _target:
var dir := ( _target.global_position - global_position ).normalized()
velocity = dir * move_speed
else:
velocity = Vector2.ZERO
move_and_slide()
# AggroLink から呼ばれる想定のメソッド
func set_aggro(active: bool) -> void:
_is_aggro = active
# ここでターゲット(プレイヤー)をセットするなど
if active:
_target = get_tree().get_first_node_in_group("player")
else:
_target = null
# ダメージを受けたときに呼ばれるメソッド例
func apply_damage(amount: float, from: Node) -> void:
# HP計算などは省略
emit_signal("damaged", amount, from)
ポイントは:
signal damaged(amount, from)を定義しておくset_aggro(active: bool)メソッドを用意しておく
この2つさえあれば、AggroLink 側のデフォルト設定のまま動きます。
手順①:AggroLink.gd を用意する
上記の AggroLink.gd をプロジェクトに追加して保存します。class_name AggroLink を付けているので、スクリプトの場所はどこでもOKです。
手順②:敵ノードに AggroLink をアタッチする
例として、こんなシーン構成にしてみます。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── AggroLink (Node)
Enemyシーンを開く- 子ノードとして
Nodeを追加し、名前をAggroLinkにする - そのノードに
AggroLink.gdをアタッチする
これで「Enemy がダメージを受ける → AggroLink が仲間呼びを発動」のルートが整います。
手順③:敵をグループに登録する
AggroLink は「同じグループの敵」を探して仲間呼びします。
デフォルトでは ally_group_name = "enemies" になっているので、敵を enemies グループに入れておきましょう。
やり方は2通りあります。
方法A:エディタから設定
- Enemy シーンのルートノード(CharacterBody2D)を選択
- 右側の「ノード」タブ → 「グループ」
enemiesと入力して「追加」
方法B:コードから設定
func _ready() -> void:
add_to_group("enemies")
どちらか一方でOKです。
手順④:実際に動かしてみる
テスト用のシーン構成例:
Main (Node2D) ├── Player (CharacterBody2D) │ ├── Sprite2D │ └── CollisionShape2D ├── Enemy1 (Enemy.tscn) ├── Enemy2 (Enemy.tscn) └── Enemy3 (Enemy.tscn)
Playerはplayerグループに入れておく(set_aggroで参照しているため)- Enemy1 / Enemy2 / Enemy3 は少し距離を空けて配置
ゲームを再生して、Enemy1 だけをプレイヤーで攻撃して apply_damage() を呼びます。
Enemy1 が damaged シグナルを emit → AggroLink が検知 → 周囲の enemies を探索 → set_aggro(true) が呼ばれる、という流れで、
Enemy2 / Enemy3 も同時にプレイヤーを追いかけ始めれば成功です。
敵同士の距離が遠すぎて反応しない場合は、AggroLink の aggro_radius を大きくしてみてください。
メリットと応用
メリット1:敵AIのスクリプトがスリムになる
「仲間呼び」のロジックを敵ごとに書かなくてよくなるので、
敵側のスクリプトは「移動」「攻撃」「アニメーション」など、本来の責務に集中できます。
また、仲間呼びの仕様変更(半径を変えたい、対象グループを変えたい、など)があっても、
AggroLink コンポーネント側だけを修正すればよく、継承ツリーをたどって修正する必要がありません。
メリット2:シーン構造がフラットで見通しがよい
Godot では、ついノード階層を深くして「敵ノードの中にAIノードの中に状態マシンの中に…」とやりがちですが、
AggroLink のような小さなコンポーネントをフラットにぶら下げていくスタイルにすると、
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── Health (Node) ├── AggroLink (Node) └── LootDropper (Node)
のように、「この敵は何ができるのか」が一目でわかる構成になります。
メリット3:敵ごとに挙動を差し替えやすい
例えば、ボスだけは「広範囲に仲間呼びする」「特定のグループだけ呼ぶ」といった調整をしたい場合、
継承ではなくコンポーネントのパラメータを変えるだけで差別化できます。
- 雑魚A:
aggro_radius = 300,ally_group_name = "goblins" - ボス:
aggro_radius = 1000,ally_group_name = "goblins_elite"
同じ AggroLink.gd を使い回しつつ、インスタンスごとに挙動を変えられるのがコンポーネント方式の強みですね。
改造案:クールダウン付きの仲間呼びにする
「1秒間に何度もダメージを受けたとき、毎回リンク発動されるのはちょっと…」という場合、
簡単なクールダウンをつけると落ち着きます。
# AggroLink.gd の一部を改造する例
@export_group("Cooldown")
@export var use_cooldown: bool = true
@export var cooldown_time: float = 1.5 # 秒
var _cooldown_timer: float = 0.0
func _process(delta: float) -> void:
if _cooldown_timer > 0.0:
_cooldown_timer -= delta
func trigger_aggro_link() -> void:
if not enabled:
return
if use_cooldown and _cooldown_timer > 0.0:
return # まだクールダウン中
_cooldown_timer = cooldown_time
# ここから先は元の処理と同じ
if _owner_node == null:
return
if _world == null:
_world = get_tree().current_scene
if _world == null:
return
var owner_global_position := _get_owner_global_position()
if owner_global_position == null:
push_warning("AggroLink: 親ノードの位置を取得できませんでした。")
return
var allies: Array = _get_allies_in_radius(owner_global_position, aggro_radius)
for ally in allies:
if ally == _owner_node:
continue
if ally.has_method(ally_aggro_method):
ally.call(ally_aggro_method, ally_aggro_value)
このように、小さな責務を持ったコンポーネントにしておけば、
「クールダウンを付ける」「呼び出すメソッドを変える」「3D専用のバージョンを作る」などの改造もやりやすくなります。
ぜひ、敵AI周りは「継承より合成」で、AggroLink みたいなコンポーネントをどんどん増やしていってみてください。




