敵AIを作るとき、つい
EnemyBase を継承して…その中にパトロール・索敵・攻撃・死亡処理を全部書く」
みたいな巨大クラスを作りがちですよね。最初は良くても、あとから「特攻する敵」「遠距離攻撃だけする敵」「当たったら爆発するギミック」などを増やしたくなると、一気に地獄化します。

さらに Godot 標準のやり方に寄せると、

  • ノード階層が深くなりがち(Enemy/Base/AI/Attack/Kamikaze... みたいなツリー)
  • 敵の種類ごとにシーンを増やして、似たようなスクリプトをコピペしがち
  • 「この敵だけ特攻させたい」みたいな要望に柔軟に対応しづらい

そこで今回は「継承よりコンポーネント」の発想で、
どんな敵やギミックにも後付けできる 特攻AIコンポーネント を用意してしまいましょう。

【Godot 4】見つけたら即ダッシュ&爆散!「Kamikaze」コンポーネント

Kamikaze コンポーネントは、ざっくり言うとこんな振る舞いをします。

  • ターゲット(通常はプレイヤー)を検知したら移動速度アップ
  • ターゲットに向かってまっすぐ突っ込む
  • 接触した瞬間に自爆し、ダメージを与えて自分は消滅

これを 1つのスクリプト + 1つのノード として完結させ、
敵本体は「move_and_slide するだけ」のシンプルな実装に保つ、というのが今回の狙いです。


フルコード:Kamikaze.gd


extends Node
class_name Kamikaze
"""
特攻AIコンポーネント。

前提:
- 親ノードが CharacterBody2D または RigidBody2D 的な「自分で動く」ノードであることを想定
- 親に `velocity` プロパティ (CharacterBody2D と同じ) があることを前提にしている
  → Player や Enemy を CharacterBody2D で作っている場合はそのまま使える

役割:
- ターゲット検知 (距離・視野角ベース)
- ターゲットに向かって移動ベクトルを指示
- 接触時に自爆ダメージを与える
"""

# === 基本設定 ===

@export var enabled: bool = true:
	set(value):
		enabled = value
		set_process(value)
		set_physics_process(value)

@export var target_path: NodePath:
	## 追いかけるターゲット (例: プレイヤー) の NodePath
	## 空の場合は自動で "Player" という名前のノードをシーンツリーから探す
	get:
		return target_path
	set(value):
		target_path = value
		_target = null  # 再解決させる

@export var normal_speed: float = 80.0:
	## 通常時の移動速度 (ターゲット未発見時)
	set(value):
		normal_speed = max(0.0, value)

@export var rush_speed: float = 220.0:
	## 特攻モード時の移動速度
	set(value):
		rush_speed = max(0.0, value)

@export var acceleration: float = 600.0:
	## 現在速度を目標速度へ近づける加速度
	set(value):
		acceleration = max(0.0, value)

# === 索敵設定 ===

@export var detection_radius: float = 220.0:
	## この半径以内にターゲットが入ったら「発見」とみなす
	set(value):
		detection_radius = max(0.0, value)

@export var lose_sight_radius: float = 280.0:
	## この半径より外に出たら「見失い」とみなす
	## detection_radius より少し大きくしてヒステリシスを持たせると挙動が安定する
	set(value):
		lose_sight_radius = max(0.0, value)

@export_range(0.0, 180.0, 1.0)
var view_angle_deg: float = 180.0:
	## 視野角 (度数)。180 なら半円、360 にしたければ 180 を超えても良いが
	## 実質「常に見える」状態に近くなる
	set(value):
		view_angle_deg = clamp(value, 0.0, 180.0)

@export var require_line_of_sight: bool = false
## true の場合、RayCast2D を使って壁越しには検知しないようにする
## 壁レイヤーなどを使う場合は raycast_collision_mask を調整する

@export_flags_2d_physics var raycast_collision_mask: int = 1:
	## require_line_of_sight = true のときに使う RayCast2D のコリジョンマスク
	## 壁や障害物が乗っているレイヤーを指定

# === 自爆ダメージ設定 ===

@export var damage: float = 20.0:
	## 自爆時に与えるダメージ量
	set(value):
		damage = max(0.0, value)

@export var kill_self_on_explode: bool = true:
	## 自爆ダメージを与えたあと、この敵自身を queue_free するかどうか

@export var explosion_scene: PackedScene:
	## 爆発エフェクト (任意)。指定されていれば自爆時にインスタンス化して再生する

@export var explosion_offset: Vector2 = Vector2.ZERO:
	## 爆発エフェクトの表示位置オフセット (親ノードのローカル座標基準)

# === 接触判定設定 ===

@export var use_body_entered_signal: bool = true:
	## true: 親の子にある Area2D の body_entered を使って接触検出
	## false: 単純に距離ベースで「接触した」とみなす (簡易モード)

@export var contact_radius: float = 16.0:
	## use_body_entered_signal = false のとき、
	## この半径以内にターゲットが入ったら接触とみなして自爆する
	set(value):
		contact_radius = max(0.0, value)

# === 内部状態 ===

var _parent_body: Node = null
var _target: Node2D = null
var _current_speed: float = 0.0
var _is_rushing: bool = false
var _raycast: RayCast2D = null

# 外部からも読める状態フラグ
var is_rushing: bool:
	get:
		return _is_rushing

func _ready() -> void:
	_parent_body = get_parent()
	if not is_instance_valid(_parent_body):
		push_warning("Kamikaze: 親ノードが存在しません。CharacterBody2D などにアタッチしてください。")
		set_process(false)
		set_physics_process(false)
		return

	# 親ノードに velocity プロパティがあるか軽くチェック
	if not _parent_body.has_variable("velocity") and not _parent_body.has_method("set_velocity"):
		push_warning("Kamikaze: 親ノードに 'velocity' プロパティがありません。CharacterBody2D ベースを推奨します。")

	# ターゲットを自動取得 (必要なら)
	_resolve_target()

	# 視線判定用 RayCast2D を内部的に用意 (必要なときだけ)
	if require_line_of_sight:
		_raycast = RayCast2D.new()
		_raycast.enabled = true
		_raycast.collision_mask = raycast_collision_mask
		add_child(_raycast)

	# 親の子に Area2D がある場合、自動で body_entered に接続してみる
	if use_body_entered_signal:
		_connect_area_signals()

	set_process(enabled)
	set_physics_process(enabled)


func _physics_process(delta: float) -> void:
	if not enabled:
		return

	if not is_instance_valid(_parent_body):
		return

	if not is_instance_valid(_target):
		_resolve_target()

	# ターゲットがいない場合は何もしない(または将来パトロール処理などを追加してもよい)
	if not is_instance_valid(_target):
		return

	var parent_global_pos := _get_parent_global_position()
	var target_global_pos := _target.global_position
	var to_target: Vector2 = target_global_pos - parent_global_pos
	var distance: float = to_target.length()

	# 索敵状態の更新
	_update_detection_state(to_target, distance)

	# 目標速度を決める
	var desired_speed: float = normal_speed
	if _is_rushing:
		desired_speed = rush_speed

	# 現在速度を目標速度に近づける (簡易的な加速処理)
	_current_speed = move_toward(_current_speed, desired_speed, acceleration * delta)

	# 移動方向は常にターゲット方向に向ける
	var direction: Vector2 = Vector2.ZERO
	if distance > 0.001:
		direction = to_target.normalized()

	var velocity: Vector2 = direction * _current_speed

	# 親ノードに velocity を適用
	_apply_velocity_to_parent(velocity)

	# 簡易接触判定 (距離ベース)
	if not use_body_entered_signal and distance <= contact_radius:
		_explode(_target)


func _update_detection_state(to_target: Vector2, distance: float) -> void:
	# 既にラッシュ中なら、見失い判定だけ行う
	if _is_rushing:
		if distance > lose_sight_radius:
			_is_rushing = false
		return

	# まだラッシュしていない場合、発見判定を行う
	if distance > detection_radius:
		return

	# 視野角チェック
	if view_angle_deg < 179.9:
		var forward: Vector2 = Vector2.RIGHT
		# 親の回転を考慮 (親が回転している場合)
		if _parent_body is Node2D:
			forward = forward.rotated(_parent_body.rotation)
		var angle_to_target_deg := rad_to_deg(forward.angle_to(to_target.normalized())).abs()
		if angle_to_target_deg > view_angle_deg * 0.5:
			return

	# ラインオブサイト (視線) チェック
	if require_line_of_sight and _raycast:
		_raycast.global_position = _get_parent_global_position()
		_raycast.target_position = to_target
		_raycast.force_raycast_update()
		if _raycast.is_colliding():
			# 何かに遮られているので検知しない
			return

	# ここまで来たら「発見」
	_is_rushing = true


func _apply_velocity_to_parent(velocity: Vector2) -> void:
	# CharacterBody2D を想定した処理
	if "velocity" in _parent_body:
		_parent_body.velocity = velocity
	elif _parent_body.has_method("set_velocity"):
		_parent_body.set_velocity(velocity)
	else:
		# どうしようもない場合は位置を直接動かす (推奨しないがフォールバックとして)
		if _parent_body is Node2D:
			_parent_body.global_position += velocity * get_physics_process_delta_time()


func _get_parent_global_position() -> Vector2:
	if _parent_body is Node2D:
		return _parent_body.global_position
	return Vector2.ZERO


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 が空、または解決失敗した場合、"Player" を自動探索
	var tree := get_tree()
	if not tree:
		return
	var candidates := tree.get_nodes_in_group("player")
	if candidates.size() > 0 and candidates[0] is Node2D:
		_target = candidates[0]
		return

	# グループがなければ名前で検索 (あくまでおまけ)
	var root := tree.current_scene
	if root:
		var found := root.find_child("Player", true, false)
		if found and found is Node2D:
			_target = found


func _connect_area_signals() -> void:
	# 親の直下にある Area2D を探し、body_entered を接続する
	if not is_instance_valid(_parent_body):
		return

	for child in _parent_body.get_children():
		if child is Area2D:
			if not child.is_connected("body_entered", Callable(self, "_on_area_body_entered")):
				child.body_entered.connect(_on_area_body_entered)
			# 複数あってもひとまず全部繋いでおく
			# break してもよいが、柔軟性のため全て接続


func _on_area_body_entered(body: Node) -> void:
	if not enabled:
		return
	if not is_instance_valid(body):
		return
	if not is_instance_valid(_target):
		return

	# 自爆対象は「ターゲット本人」かどうかで判定
	if body == _target:
		_explode(body)


func _explode(target: Node) -> void:
	if not is_instance_valid(_parent_body):
		return

	# 既に自爆済みなら二重で処理しないようにする
	enabled = false

	# ダメージインターフェイス:
	# - target が `apply_damage(amount: float, source: Node)` を持っていればそれを呼ぶ
	# - それ以外の場合はシグナルなどで外側に通知する形にしても良い
	if target.has_method("apply_damage"):
		target.apply_damage(damage, _parent_body)
	else:
		# 何もなければとりあえずログだけ
		print_debug("Kamikaze: target has no 'apply_damage' method. Damage=", damage)

	# 爆発エフェクト
	if explosion_scene:
		var explosion := explosion_scene.instantiate()
		if _parent_body is Node2D:
			explosion.global_position = _parent_body.global_position + explosion_offset.rotated(_parent_body.rotation)
		get_tree().current_scene.add_child(explosion)

	# 自身の破壊
	if kill_self_on_explode:
		_parent_body.queue_free()

使い方の手順

ここでは 2D アクションゲームを想定して、特攻してくる敵 を作る例で解説します。

シーン構成例

Player (CharacterBody2D)  # プレイヤー
 ├── Sprite2D
 └── CollisionShape2D

KamikazeEnemy (CharacterBody2D)  # 特攻敵
 ├── Sprite2D
 ├── CollisionShape2D
 ├── HitArea (Area2D)            # 接触検出用
 │    └── CollisionShape2D
 └── Kamikaze (Node)             # ★今回のコンポーネント

Player には apply_damage(amount, source) を実装しておくとダメージが通ります。

手順①:プレイヤー側にダメージ処理を用意する

最低限、こんな感じのメソッドを Player スクリプトに追加しておきましょう。


# Player.gd (抜粋)
extends CharacterBody2D

var hp: float = 100.0

func apply_damage(amount: float, source: Node) -> void:
	hp -= amount
	print("Player took ", amount, " damage from ", source.name, ". HP=", hp)
	if hp <= 0.0:
		_die()

func _die() -> void:
	print("Player died")
	# リスポーン処理やゲームオーバー表示など

この apply_damage があることで、Kamikaze 側から「ターゲットにダメージを投げる」だけで完結します。

手順②:敵シーンに Kamikaze コンポーネントを追加

  1. KamikazeEnemy シーンを開く(CharacterBody2D ベースを推奨)。
  2. 子ノードとして Node を追加し、そこに Kamikaze.gd をアタッチ。
  3. 名前を Kamikaze にしておくと分かりやすいです。

インスペクタで以下のように設定してみましょう。

  • target_path:空のままで OK(自動で Player を探します)
  • normal_speed:40(うろうろ移動させたいなら、親スクリプト側でパトロール処理を追加しても良いです)
  • rush_speed:220 ~ 300 くらい(ゲームのテンポに合わせて)
  • detection_radius:200 ~ 300
  • use_body_entered_signal:true(HitArea の body_entered を自動で拾います)
  • explosion_scene:爆発エフェクトのシーンがあれば指定

手順③:HitArea(Area2D)を用意して接触検出

KamikazeEnemy の子として Area2D を作り、CollisionShape2D を付けて「当たり判定の円」を作っておきます。

KamikazeEnemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── HitArea (Area2D)       # ← ここ
 │    └── CollisionShape2D
 └── Kamikaze (Node)

Kamikaze は、親の直下にある Area2D を自動で探して body_entered を接続します。
そのため、特に手動でシグナルを繋がなくても、プレイヤーが HitArea に入った瞬間に _explode() が呼ばれます。

もし「シンプルに距離だけで判定したい」場合は、

  • use_body_entered_signal = false
  • contact_radius に適当な値(例: 16 ~ 24)を設定

とすれば、HitArea すら不要になります。

手順④:親(敵本体)の移動処理をシンプルに保つ

Kamikaze は「速度ベクトルを決める」だけで、実際の移動(move_and_slide())は親ノード側で行う想定です。
例として、敵本体のスクリプトはこんな感じにできます。


# KamikazeEnemy.gd
extends CharacterBody2D

func _physics_process(delta: float) -> void:
	# Kamikaze コンポーネントが velocity を上書きしてくれるので、
	# ここでは move_and_slide() するだけで OK
	move_and_slide()

「パトロールさせたい」「壁に当たったら向きを変えたい」などは、このスクリプト側で velocity をいじれば良く、
特攻ロジックは完全に Kamikaze コンポーネントに閉じ込められています。


メリットと応用

  • 敵シーンがスリムになる
    敵ごとに「特攻 AI ロジック」をコピペする必要がなく、
    敵本体は「移動」「アニメーション」「HP管理」だけに集中できます。
  • どんなノードにも後付けできる
    プレイヤー、敵、動く爆弾、ギミック…など、
    CharacterBody2D ベースならとりあえず Kamikaze を生やすだけで特攻化できます。
  • レベルデザイン側で調整しやすい
    detection_radiusrush_speed をインスペクタからいじるだけで、
    「ゆっくり気づく敵」「視界が狭い敵」「即ダッシュしてくる狂犬」などを作り分けられます。
  • コンポーネントの組み合わせがしやすい
    例えば、別途 Health コンポーネントや Patrol コンポーネントを用意しておけば、
    「パトロールするけど、プレイヤーを見つけたら特攻して自爆する敵」がノードの組み合わせだけで作れます。

継承ベースだと「EnemyBasePatrolEnemyKamikazePatrolEnemy → …」と
クラスツリーがどんどん伸びてしまいますが、コンポーネントなら

  • Enemy (CharacterBody2D)
  • Patrol (Node)
  • Kamikaze (Node)
  • Health (Node)

のように「レゴブロックを足す」感覚で機能を盛り付けできます。

改造案:HPが一定以下になったら特攻モードに入る

例えば「普段は普通の敵だけど、HP が 30% を切ったら特攻モードに変身する」という挙動を作りたい場合、
Kamikazetrigger_rush() のようなメソッドを追加しておくと便利です。


# Kamikaze.gd に追記 (例)

func trigger_rush() -> void:
	"""
	外部から強制的に特攻モードへ移行させるためのメソッド。
	HP が減ったときや、一定時間経過後などに呼び出す想定。
	"""
	_is_rushing = true
	_current_speed = max(_current_speed, normal_speed)

これを利用して、敵本体の Health コンポーネントやスクリプトから


# EnemyHealth.gd (例)
func _on_hp_changed(new_hp: float, max_hp: float) -> void:
	if new_hp / max_hp <= 0.3:
		var kamikaze := owner.get_node_or_null("Kamikaze")
		if kamikaze:
			kamikaze.trigger_rush()

のように呼び出せば、「瀕死になったら特攻する敵」が簡単に作れます。
このように、小さなメソッドを 1 個足すだけでコンポーネントの応用範囲が一気に広がるのが、合成スタイルの気持ちいいところですね。