Godotで敵AIを書くとき、つい「敵ベースシーン」を作って、そこからどんどん継承していく…というやり方をしがちですよね。
でも、「この敵は追跡するけど、この敵はしない」「こっちは索敵範囲だけ変えたい」みたいな要件が増えてくると、継承ツリーがどんどんカオスになっていきます。

さらにありがちなのが、_physics_process() の中に

// 擬似コード
if is_player_near():
    # 追跡処理
else:
    # 徘徊処理

みたいな「状態管理 + 移動ロジック + 距離判定」が全部ベタ書きされていて、別の敵にも流用しづらいパターンです。

そこで今回は、「プレイヤーが範囲内に入ったら追跡モードに切り替える」というよくあるロジックを、
敵ノードにポン付けできるコンポーネントとして切り出した AggroSystem を紹介します。
敵の「敵対管理(Aggro)」だけを独立させて、継承より合成でスッキリ実装していきましょう。

【Godot 4】索敵も追跡もコンポーネントに丸投げ!「AggroSystem」コンポーネント

コンポーネントの概要

  • プレイヤーが一定距離内に入ると Aggro(追跡モード) に切り替え
  • 一定距離より離れたら 非Aggro(待機/巡回モード) に戻る
  • 敵本体には「今Aggro中かどうか」だけを通知し、移動ロジックは敵側に任せる
  • プレイヤー検出は 距離ベース(Physics不要)
  • シグナルで状態変化を通知(aggro_started / aggro_ended

つまり、AggroSystem は「敵対状態のフラグ管理 + 範囲チェック」に特化したコンポーネントです。
敵キャラは「Aggro中ならプレイヤーに向かって移動する」「Aggroじゃないならうろうろする」だけ実装すればOK、という分業スタイルにできます。


GDScript フルコード


extends Node
class_name AggroSystem
## 敵対管理(Aggro)コンポーネント
## 親ノード(敵)とは「今Aggroかどうか」と「ターゲット位置」を共有するだけにして、
## 状態管理そのものをこのコンポーネントに閉じ込めます。

## --- シグナル定義 ---
## Aggro開始時(プレイヤーを見つけた瞬間)に発火
signal aggro_started(target: Node2D)
## Aggro終了時(プレイヤーを見失った瞬間)に発火
signal aggro_ended()

## --- エクスポート変数(インスペクタから調整可能) ---

@export_group("Aggro Settings")
## プレイヤーを検知する最大距離(ピクセル)
@export var detection_radius: float = 200.0

## Aggro解除に使う距離。detection_radius より少し大きめにすると
## 「入った瞬間Aggro → 1フレームで解除」のようなチラつきを防げます。
@export var lose_radius: float = 260.0

## ターゲット(通常はプレイヤー)への参照。
## 空の場合は、自動的にシーンツリーから Player という名前のノードを探します。
@export var target_path: NodePath

## 何フレーム(何秒)ごとに距離チェックを行うか。
## 0 の場合は毎フレームチェック(もっとも反応が良いが負荷は高め)。
@export_range(0.0, 1.0, 0.01, "or_greater")
var check_interval_sec: float = 0.1

@export_group("Debug")
## デバッグ用: エディタ上&ゲーム中に索敵円を描画するか
@export var debug_draw: bool = false
## デバッグ描画の色
@export var debug_color_aggro: Color = Color.RED
@export var debug_color_idle: Color = Color(0, 1, 0, 0.6)

## --- 内部状態 ---

## 現在Aggro中かどうか
var is_aggro: bool = false:
    set(value):
        if is_aggro == value:
            return
        is_aggro = value
        if is_aggro:
            emit_signal("aggro_started", _target)
        else:
            emit_signal("aggro_ended")
        # デバッグ描画の更新
        queue_redraw()

## 現在のターゲット。主にプレイヤー。
var _target: Node2D

## チェック用の時間蓄積
var _time_accumulated: float = 0.0


func _ready() -> void:
    # ターゲットの自動解決
    _resolve_target()

    # 親ノードが2D系(Node2D系統)であることを軽くチェック
    if not (get_parent() is Node2D):
        push_warning("AggroSystem は Node2D 系のノードにアタッチすることを想定しています。親: %s" % get_parent())

    # 最初は非Aggro
    is_aggro = false


func _process(delta: float) -> void:
    if check_interval_sec <= 0.0:
        # 毎フレームチェック
        _update_aggro_state()
        return

    # 一定間隔ごとにチェック
    _time_accumulated += delta
    if _time_accumulated >= check_interval_sec:
        _time_accumulated = 0.0
        _update_aggro_state()


func _update_aggro_state() -> void:
    if not is_instance_valid(_target):
        # ターゲットが消えたら自動的に非Aggroへ
        if is_aggro:
            is_aggro = false
        _resolve_target()
        return

    var parent_node := get_parent()
    if not (parent_node is Node2D):
        return

    # 親ノード(敵)とターゲット(プレイヤー)の距離を測る
    var distance := (parent_node.global_position - _target.global_position).length()

    if not is_aggro:
        # まだAggroしていない状態 → detection_radius を超えたら何もしない
        if distance <= detection_radius:
            is_aggro = true
    else:
        # すでにAggro中 → lose_radius 以上離れたら解除
        if distance >= lose_radius:
            is_aggro = false


func _resolve_target() -> void:
    ## ターゲット(プレイヤー)をシーンから探すロジック
    if target_path != NodePath():
        var node := get_node_or_null(target_path)
        if node and node is Node2D:
            _target = node
            return

    # target_path が設定されていない、または無効な場合:
    # 1. ルートから "Player" という名前のノードを探す
    var tree := get_tree()
    if tree:
        var root := tree.current_scene
        if root:
            var candidate := root.find_child("Player", true, false)
            if candidate and candidate is Node2D:
                _target = candidate
                return

    # 見つからなかった場合は null のまま
    _target = null


func get_target() -> Node2D:
    ## 現在のターゲット(プレイヤー)を返すヘルパー
    return _target


func is_aggro_active() -> bool:
    ## 外部から読み取りやすいようにヘルパー関数を用意
    return is_aggro


func _draw() -> void:
    if not debug_draw:
        return

    var parent_node := get_parent()
    if not (parent_node is Node2D):
        return

    # 親ノードのローカル座標系で円を描く
    # Aggro中かどうかで色を変える
    var color := is_aggro ? debug_color_aggro : debug_color_idle

    # Node 自身は原点(0,0)なので、親の原点に描くイメージでOK
    draw_circle(Vector2.ZERO, detection_radius, color.with_alpha(0.2))
    draw_arc(Vector2.ZERO, detection_radius, 0, TAU, 64, color, 2.0)

使い方の手順

例1: 追跡する敵(Enemy)に AggroSystem をアタッチする

プレイヤーを見つけたら追いかける、シンプルな敵を想定します。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── AggroSystem (Node)
  1. Enemy シーンを作成
    • ルート: CharacterBody2D(名前: Enemy
    • 子に Sprite2D, CollisionShape2D を追加
    • さらに子として Node を追加し、スクリプトに上記の AggroSystem.gd をアタッチ
  2. Enemy 本体の移動ロジックを書く
    Enemy のスクリプト例(Enemy.gd)です。AggroSystem の状態を読むだけにしています。

extends CharacterBody2D

@export var move_speed: float = 120.0

var _aggro: AggroSystem


func _ready() -> void:
    # 子ノードから AggroSystem を取得
    _aggro = $AggroSystem

    # Aggro開始/終了時のイベントにフックしてみる(任意)
    _aggro.aggro_started.connect(_on_aggro_started)
    _aggro.aggro_ended.connect(_on_aggro_ended)


func _physics_process(delta: float) -> void:
    velocity = Vector2.ZERO

    if _aggro.is_aggro_active():
        var target := _aggro.get_target()
        if target:
            var dir := (target.global_position - global_position).normalized()
            velocity = dir * move_speed
    else:
        # 非Aggro時の処理(例: その場で待機)
        velocity = Vector2.ZERO

    move_and_slide()


func _on_aggro_started(target: Node2D) -> void:
    print("Enemy: Aggro開始!ターゲット: ", target.name)


func _on_aggro_ended() -> void:
    print("Enemy: Aggro終了、待機モードに戻ります。")
  1. AggroSystem のパラメータを調整
    Enemy シーンで AggroSystem ノードを選択し、インスペクタから以下を設定します。
    • detection_radius: 200〜300 くらい(好みで)
    • lose_radius: detection_radius + 50〜100 くらい
    • check_interval_sec: 0.05〜0.2(反応速度とパフォーマンスのバランス)
    • debug_draw: ON にすると索敵範囲が見えて便利
    • target_path: 空でもOK(自動で Player を探します)
  2. プレイヤーシーンを用意して名前を「Player」にする
    AggroSystem はデフォルトで current_scene 以下から "Player" という名前のノードを探します。
    既にあるプレイヤーシーンのルートノード名を Player にしておきましょう。例:
Player (CharacterBody2D)
 ├── Sprite2D
 └── CollisionShape2D

これで、ゲーム開始後にプレイヤーが detection_radius 内に入ると敵が追跡を開始し、lose_radius 以上に離れると追跡をやめるようになります。


例2: 動く砲台(Turret)が Aggro 中だけプレイヤーを狙う

Turret (Node2D)
 ├── Sprite2D
 ├── AggroSystem (Node)
 └── Barrel (Node2D)

Turret 自体は動かず、Aggro 中だけ砲身(Barrel)がプレイヤーの方向を向く例です。


extends Node2D

@export var rotate_speed: float = 5.0

var _aggro: AggroSystem
var _barrel: Node2D


func _ready() -> void:
    _aggro = $AggroSystem
    _barrel = $Barrel


func _process(delta: float) -> void:
    if not _aggro.is_aggro_active():
        return

    var target := _aggro.get_target()
    if not target:
        return

    var to_target := (target.global_position - global_position).angle()
    rotation = lerp_angle(rotation, to_target, rotate_speed * delta)

このように、敵の種類が違っても AggroSystem をそのまま流用できるのがコンポーネント方式の強みですね。


メリットと応用

  • 敵の「行動」と「敵対状態管理」が分離
    敵本体のスクリプトから「プレイヤーとの距離測定」「状態遷移」のロジックが消え、
    if _aggro.is_aggro_active(): 追跡処理 のようにシンプルになります。
  • シーン構造がフラットで見通しが良い
    EnemyWithAggro, EnemyWithAggroAndPatrol のような継承ツリーを増やさず、
    「AggroSystem コンポーネントを付けるかどうか」で機能を切り替えられます。
  • パラメータで「性格」を簡単に変えられる
    detection_radiuslose_radius を変えるだけで、

    • 視野が狭いけどしつこく追いかける敵

    • 視野が広いけどすぐに諦める敵


    など、バリエーションを量産できます。


  • レベルデザインが楽になる
    ステージ上に敵をポンポン配置して、インスペクタのパラメータをいじるだけで「この敵はここまで来たら気づく」といった
    細かなチューニングができます。シーンごとにスクリプトを書き換える必要がありません。

改造案: 視界(FOV)を追加して「背後からならバレない」仕様にする

距離だけでなく、視界角度も条件に含めると、よりゲームらしい挙動になります。
簡易的に「前方◯度以内にプレイヤーがいるときだけ Aggro する」処理を追加する例です。


@export_group("Vision")
## 視界角度(度数法)。0 の場合は全方向OK。
@export_range(0.0, 360.0, 1.0)
var vision_angle_deg: float = 0.0


func _can_see_target(parent_node: Node2D) -> bool:
    if not is_instance_valid(_target):
        return false
    if vision_angle_deg <= 0.0 or vision_angle_deg >= 360.0:
        return true

    # 親ノードの「前方」を rotation から計算(右向きを前とする場合)
    var forward := Vector2.RIGHT.rotated(parent_node.global_rotation)
    var to_target := (_target.global_position - parent_node.global_position).normalized()

    var angle_between := rad_to_deg(forward.angle_to(to_target))
    return abs(angle_between) <= vision_angle_deg * 0.5

この _can_see_target()_update_aggro_state() 内で距離判定と組み合わせれば、
「背後から近づけばバレない敵」などもコンポーネントの改造だけで実現できます。

こんな感じで、AggroSystem をベースに自分のゲーム専用の敵対コンポーネントを育てていくと、
プロジェクト全体のAIまわりがかなり整理されていきます。継承ツリーで悩む前に、まずはコンポーネント化してみましょう。