【Godot 4】AggroLink (仲間呼び) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

敵AIを書いていると、つい「敵ごとにスクリプトを継承して増殖させる」パターンにハマりがちですよね。
例: BaseEnemy.gd を継承した Goblin.gd / Orc.gd / Slime.gd … みたいなやつです。

その中に「仲間呼び(アグロリンク)」のロジックをベタ書きし始めると、

  • 敵ごとに微妙に挙動が違って、コピペが増える
  • 「この敵だけ仲間呼びオフにしたい」が面倒
  • 後から仕様変更したら、全部の敵スクリプトを修正するハメになる

といった、継承ベースのつらみが一気に襲ってきます。

そこで今回は、「攻撃を受けたら、周囲にいる同じグループの敵を一斉にアクティブ化する」処理を
ひとつの独立コンポーネントとして切り出した AggroLink を用意しました。

敵側は「自分をアクティブにするメソッド」さえ持っていればOK。
あとはこのコンポーネントをペタっと貼るだけで、仲間呼びAIを後付けできるようにしていきましょう。


コンポーネントの考え方

このコンポーネントは、ざっくり言うと:

  • 「ダメージを受けた」イベントをどこかから受け取る
  • 周囲にいる「同じグループ」の敵を検索する
  • その敵たちの 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)
  1. Enemy シーンを開く
  2. 子ノードとして Node を追加し、名前を AggroLink にする
  3. そのノードに AggroLink.gd をアタッチする

これで「Enemy がダメージを受ける → AggroLink が仲間呼びを発動」のルートが整います。


手順③:敵をグループに登録する

AggroLink は「同じグループの敵」を探して仲間呼びします。
デフォルトでは ally_group_name = "enemies" になっているので、敵を enemies グループに入れておきましょう。

やり方は2通りあります。

方法A:エディタから設定
  1. Enemy シーンのルートノード(CharacterBody2D)を選択
  2. 右側の「ノード」タブ → 「グループ」
  3. 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)
  • Playerplayer グループに入れておく(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 みたいなコンポーネントをどんどん増やしていってみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

URLをコピーしました!