敵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 コンポーネントを追加
KamikazeEnemyシーンを開く(CharacterBody2Dベースを推奨)。- 子ノードとして
Nodeを追加し、そこにKamikaze.gdをアタッチ。 - 名前を
Kamikazeにしておくと分かりやすいです。
インスペクタで以下のように設定してみましょう。
target_path:空のままで OK(自動で Player を探します)normal_speed:40(うろうろ移動させたいなら、親スクリプト側でパトロール処理を追加しても良いです)rush_speed:220 ~ 300 くらい(ゲームのテンポに合わせて)detection_radius:200 ~ 300use_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 = falsecontact_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_radiusやrush_speedをインスペクタからいじるだけで、
「ゆっくり気づく敵」「視界が狭い敵」「即ダッシュしてくる狂犬」などを作り分けられます。 - コンポーネントの組み合わせがしやすい
例えば、別途HealthコンポーネントやPatrolコンポーネントを用意しておけば、
「パトロールするけど、プレイヤーを見つけたら特攻して自爆する敵」がノードの組み合わせだけで作れます。
継承ベースだと「EnemyBase → PatrolEnemy → KamikazePatrolEnemy → …」と
クラスツリーがどんどん伸びてしまいますが、コンポーネントなら
Enemy (CharacterBody2D)Patrol (Node)Kamikaze (Node)Health (Node)
のように「レゴブロックを足す」感覚で機能を盛り付けできます。
改造案:HPが一定以下になったら特攻モードに入る
例えば「普段は普通の敵だけど、HP が 30% を切ったら特攻モードに変身する」という挙動を作りたい場合、
Kamikaze に trigger_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 個足すだけでコンポーネントの応用範囲が一気に広がるのが、合成スタイルの気持ちいいところですね。
