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)
- Enemy シーンを作成
- ルート:
CharacterBody2D(名前:Enemy) - 子に
Sprite2D,CollisionShape2Dを追加 - さらに子として
Nodeを追加し、スクリプトに上記のAggroSystem.gdをアタッチ
- ルート:
- 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終了、待機モードに戻ります。")
- 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を探します)
- プレイヤーシーンを用意して名前を「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_radiusとlose_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まわりがかなり整理されていきます。継承ツリーで悩む前に、まずはコンポーネント化してみましょう。
