Godot で敵AIを書いていると、つい「プレイヤーに向かって突っ込むだけ」のシンプルなロジックになりがちですよね。
さらに、Enemy.gd みたいな巨大スクリプトに「移動」「射撃」「HP管理」「アニメーション制御」などを全部まとめて書いてしまうと、あとから挙動を変えたいときに地獄を見ます。
Godot 標準のやり方だと、
- 敵ごとにスクリプトを継承してカスタマイズ
- ノード階層を深くして「追尾ノード」「射撃ノード」などを子ノードで分担
といった構成になりがちですが、どちらも「依存関係が密」になってしまい、別の敵に挙動だけを移植したいときに意外と面倒です。
そこで今回は、「プレイヤーに近づきすぎず、遠すぎない一定距離を保って射撃戦を行う」というよくある敵AIを、
1つのコンポーネントとして切り出した KeepDistance コンポーネントを紹介します。
「継承より合成」で、どんな敵ノードにもポン付けできるようにしていきましょう。
【Godot 4】距離をキープして賢く射撃!「KeepDistance」コンポーネント
このコンポーネントは、ざっくり言うと以下を自動でやってくれます。
- ターゲット(通常はプレイヤー)との距離を測る
- 「近すぎる → 後退」「遠すぎる → 前進」「ちょうどいい → 停止」
- いい感じの距離にいるときだけ射撃する
敵本体は「移動速度を持った CharacterBody2D / CharacterBody3D」であればOK、
移動ベクトルをこのコンポーネントから渡してあげるだけ、というシンプル構成を目指します。
フルコード: KeepDistance.gd
extends Node
class_name KeepDistance
## ターゲットとの距離を一定範囲に保ちつつ射撃するコンポーネント
##
## 想定ターゲット: プレイヤーなど
## 想定親ノード: CharacterBody2D / CharacterBody3D など「自前で移動処理を持つ」ノード
##
## このコンポーネントは「移動方向ベクトル」と「射撃タイミング」を提供し、
## 実際の移動や弾の生成は親ノード側で行う、という分業スタイルを前提にしています。
@export_group("基本設定")
@export var target_path: NodePath
## 追尾・距離維持の対象となるノードへのパス
## 例: プレイヤーの NodePath をインスペクタで指定する
@export var min_distance: float = 120.0
## これより近づきすぎたら「離れる」方向に動く
@export var max_distance: float = 220.0
## これより遠くなったら「近づく」方向に動く
@export_range(0.0, 1.0, 0.01)
var stop_band: float = 0.15
## 距離が「ちょうどいい」とみなすバンド幅(割合)
## min と max の中間 ± この割合の範囲では移動しない
## 例: 0.15 なら、中間距離 ±15% の範囲で停止
@export_group("移動設定")
@export var move_speed: float = 120.0
## 親ノードがこの速度を使って移動ベクトルをスケールするとよい
@export_range(0.0, 1.0, 0.1)
var lateral_move_ratio: float = 0.4
## ターゲットに対して真っ直ぐ行き来するだけだと単調なので、
## ある程度「横方向(ストレイフ)」成分を混ぜる割合
## 0.0 = 一切横移動なし, 1.0 = 完全に横移動のみ
@export var use_2d: bool = true
## true: 2D 用 (Vector2), false: 3D 用 (Vector3)
## 2D/3D どちらでも使えるようにしています
@export_group("射撃設定")
@export var can_shoot: bool = true
## 射撃機能を有効にするかどうか
@export var shoot_cooldown: float = 0.8
## 1 発撃ったあと、次に撃てるまでのクールダウン時間(秒)
@export var shoot_only_in_band: bool = true
## 「ちょうどいい距離帯(stop band)にいるときのみ」射撃するかどうか
## false にすると、距離に関係なくクールダウンが空き次第撃つ
@export var auto_shoot: bool = true
## true の場合、このコンポーネント内部から射撃シグナルを自動で発火する
## false の場合、外部から should_shoot() を呼んで判定だけ使う、といった運用も可能
@export_group("デバッグ")
@export var debug_draw: bool = false
## true にすると、_draw() で距離帯を簡易表示 (2D 専用)
signal request_shoot(target: Node)
## 射撃タイミングを通知するシグナル
## 親ノード側でこのシグナルを connect して、弾生成などを行う想定
var _target: Node = null
var _shoot_timer: float = 0.0
var _move_dir := Vector2.ZERO
var _move_dir_3d := Vector3.ZERO
func _ready() -> void:
if target_path != NodePath():
_target = get_node_or_null(target_path)
# ターゲットが見つからない場合は警告だけ出しておく
if _target == null:
push_warning("KeepDistance: target not found. Set 'target_path' in the inspector.")
set_process(true)
if debug_draw and use_2d:
set_process_internal(true) # _draw を更新するため
func _process(delta: float) -> void:
if _target == null or not is_instance_valid(_target):
_move_dir = Vector2.ZERO
_move_dir_3d = Vector3.ZERO
return
_shoot_timer = maxf(_shoot_timer - delta, 0.0)
if use_2d:
_update_move_2d()
else:
_update_move_3d()
if auto_shoot and can_shoot and _shoot_timer == 0.0:
if should_shoot():
_shoot_timer = shoot_cooldown
emit_signal("request_shoot", _target)
if debug_draw and use_2d:
queue_redraw()
func _update_move_2d() -> void:
var owner_2d := owner as Node2D
if owner_2d == null:
push_warning("KeepDistance: 'use_2d' is true but owner is not Node2D.")
_move_dir = Vector2.ZERO
return
var to_target: Vector2 = (_target.global_position - owner_2d.global_position)
var distance := to_target.length()
if distance == 0.0:
_move_dir = Vector2.ZERO
return
var dir_to_target := to_target / distance
var mid := (min_distance + max_distance) * 0.5
var band_half_width := (max_distance - min_distance) * 0.5 * stop_band
# デフォルトは停止
var desired_dir := Vector2.ZERO
if distance < mid - band_half_width:
# 近すぎ → 離れる方向
desired_dir = -dir_to_target
elif distance > mid + band_half_width:
# 遠すぎ → 近づく方向
desired_dir = dir_to_target
else:
# ちょうどいい距離帯 → 移動しない
desired_dir = Vector2.ZERO
# 横方向(ストレイフ)の成分を追加して単調さを軽減
if desired_dir != Vector2.ZERO and lateral_move_ratio > 0.0:
var lateral := Vector2(-dir_to_target.y, dir_to_target.x) # 90度回転
desired_dir = desired_dir.lerp(lateral, lateral_move_ratio).normalized()
_move_dir = desired_dir
func _update_move_3d() -> void:
var owner_3d := owner as Node3D
if owner_3d == null:
push_warning("KeepDistance: 'use_2d' is false but owner is not Node3D.")
_move_dir_3d = Vector3.ZERO
return
# Y(高さ)は無視して水平距離のみで判定する例
var owner_pos := owner_3d.global_position
var target_pos := _target.global_position
owner_pos.y = 0.0
target_pos.y = 0.0
var to_target: Vector3 = target_pos - owner_pos
var distance := to_target.length()
if distance == 0.0:
_move_dir_3d = Vector3.ZERO
return
var dir_to_target := to_target / distance
var mid := (min_distance + max_distance) * 0.5
var band_half_width := (max_distance - min_distance) * 0.5 * stop_band
var desired_dir := Vector3.ZERO
if distance < mid - band_half_width:
desired_dir = -dir_to_target
elif distance > mid + band_half_width:
desired_dir = dir_to_target
else:
desired_dir = Vector3.ZERO
# 3D の場合、横方向は「右ベクトル」を使ってストレイフ
if desired_dir != Vector3.ZERO and lateral_move_ratio > 0.0:
var right := dir_to_target.cross(Vector3.UP).normalized()
desired_dir = desired_dir.lerp(right, lateral_move_ratio).normalized()
_move_dir_3d = desired_dir
func get_move_direction() -> Vector2:
## 2D 用の移動方向ベクトルを返す (正規化済み or ZERO)
return _move_dir
func get_move_direction_3d() -> Vector3:
## 3D 用の移動方向ベクトルを返す (正規化済み or ZERO)
return _move_dir_3d
func should_shoot() -> bool:
## 「今、撃ってよいか?」のロジックだけを判定する関数
## auto_shoot=false のときに、外部から使うことを想定
if not can_shoot:
return false
if _target == null or not is_instance_valid(_target):
return false
if shoot_only_in_band:
var owner_pos
var target_pos
if use_2d:
var owner_2d := owner as Node2D
if owner_2d == null:
return false
owner_pos = owner_2d.global_position
target_pos = _target.global_position
var d := (target_pos - owner_pos).length()
var mid := (min_distance + max_distance) * 0.5
var band_half_width := (max_distance - min_distance) * 0.5 * stop_band
return d >= mid - band_half_width and d <= mid + band_half_width
else:
var owner_3d := owner as Node3D
if owner_3d == null:
return false
owner_pos = owner_3d.global_position
target_pos = _target.global_position
owner_pos.y = 0.0
target_pos.y = 0.0
var d := (target_pos - owner_pos).length()
var mid3 := (min_distance + max_distance) * 0.5
var band_half_width3 := (max_distance - min_distance) * 0.5 * stop_band
return d >= mid3 - band_half_width3 and d <= mid3 + band_half_width3
# 距離に関係なく撃ってよい
return true
func force_shoot_cooldown(time: float = -1.0) -> void:
## 外部からクールダウンをリセット/延長したいときに使う
## time < 0 の場合は shoot_cooldown をそのまま使用
_shoot_timer = shoot_cooldown if time < 0.0 else maxf(time, 0.0)
func set_target(target: Node) -> void:
## ランタイムでターゲットを差し替えたい場合に使用
_target = target
if _target:
target_path = _target.get_path()
func _draw() -> void:
## デバッグ用に距離帯を円で描画 (2D 専用)
if not debug_draw or not use_2d:
return
var owner_2d := owner as Node2D
if owner_2d == null:
return
# ローカル座標で描くため、原点中心の円を描く
var mid := (min_distance + max_distance) * 0.5
var band_half_width := (max_distance - min_distance) * 0.5 * stop_band
draw_circle(Vector2.ZERO, min_distance, Color(0.2, 0.6, 1.0, 0.2))
draw_circle(Vector2.ZERO, max_distance, Color(1.0, 0.3, 0.3, 0.2))
draw_circle(Vector2.ZERO, mid - band_half_width, Color(0.3, 1.0, 0.3, 0.3))
draw_circle(Vector2.ZERO, mid + band_half_width, Color(0.3, 1.0, 0.3, 0.3))
func _process_internal(delta: float) -> void:
# debug_draw 用に _draw を定期的に呼ぶ
if debug_draw and use_2d:
queue_redraw()
使い方の手順
ここでは 2D シューティングを例に、敵がプレイヤーと一定距離を保ちながら撃ってくるシナリオで説明します。
① コンポーネントスクリプトを用意する
- 上記の
KeepDistance.gdをプロジェクト内に保存します(例:res://components/KeepDistance.gd)。 - エディタを再読み込みすると、ノードに「KeepDistance」スクリプトを直接アタッチできるようになります(
class_nameのおかげ)。
② 敵シーンにコンポーネントとしてアタッチ
敵シーンの構成例:
EnemyShooter (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── KeepDistance (Node)
EnemyShooterはCharacterBody2Dベースにしておきます。- 子ノードとして
Nodeを追加し、スクリプトにKeepDistance.gdをアタッチします。 KeepDistanceノードのインスペクタでtarget_pathにプレイヤーノードをドラッグ&ドロップmin_distance,max_distanceで好みの距離帯を調整move_speedは敵の移動速度に合わせておくと扱いやすいです
敵本体側のスクリプト例(EnemyShooter.gd)はこんな感じになります:
extends CharacterBody2D
@export var base_speed: float = 120.0
@export var bullet_scene: PackedScene
var keep_distance: KeepDistance
func _ready() -> void:
keep_distance = $KeepDistance
# 射撃リクエストを受け取る
keep_distance.request_shoot.connect(_on_request_shoot)
func _physics_process(delta: float) -> void:
if keep_distance:
# コンポーネントから移動方向をもらう
var dir := keep_distance.get_move_direction()
velocity = dir * base_speed
else:
velocity = Vector2.ZERO
move_and_slide()
func _on_request_shoot(target: Node) -> void:
if bullet_scene == null:
return
var bullet = bullet_scene.instantiate()
get_tree().current_scene.add_child(bullet)
# 弾を敵の位置からターゲット方向に発射する簡易例
bullet.global_position = global_position
if target is Node2D:
var to_target := (target.global_position - global_position).normalized()
if "velocity" in bullet:
bullet.velocity = to_target * 400.0
ポイントは、Enemy 側は「移動ベクトルをもらって動く」「シグナルを受けて撃つ」だけにしていることです。
距離管理ロジックは一切 Enemy 側に書いていません。
③ プレイヤーシーンの構成例
Player (CharacterBody2D) ├── Sprite2D └── CollisionShape2D
プレイヤーの位置さえ動いてくれれば、KeepDistance は自動的に距離を測ってくれます。
特別なインターフェースは不要で、「global_position を持っているノード」であればターゲットにできます。
④ 応用例: 動く砲台、ボスの取り巻き、動く床など
- 動く砲台:
StaticBody2DではなくCharacterBody2Dにして、KeepDistanceを付けるだけで「プレイヤーと一定距離を保つ砲台」が作れます。
- ボスの取り巻き:
- ターゲットを「ボス本体」にしておけば、ボスから一定距離を保ちつつプレイヤーを撃つ取り巻きも簡単に作れます。
- 動く床:
- 少し変則ですが、「プレイヤーから一定距離を保って追いかける足場」なんかも実現できます。足場ノードに
KeepDistanceを付けて、移動方向を足場のvelocityに流し込むだけです。
- 少し変則ですが、「プレイヤーから一定距離を保って追いかける足場」なんかも実現できます。足場ノードに
メリットと応用
KeepDistance コンポーネントを使う最大のメリットは、敵の「距離感AI」を完全に外出しできることです。
- シーン構造がスッキリ:
- 敵ノードは「移動と見た目と当たり判定」に集中でき、距離制御のロジックはコンポーネントに任せられます。
- 使い回しが容易:
- 別の敵シーンにも
KeepDistanceノードをコピペするだけで、「同じ距離感AI」を即導入できます。 - パラメータ(
min_distance,max_distance,lateral_move_ratio)を変えるだけで、動きのキャラ付けも簡単です。
- 別の敵シーンにも
- 継承地獄からの解放:
BaseEnemy.gd→RangedEnemy.gd→SniperEnemy.gdみたいな継承ツリーを作らずに済みます。- 「近接だけの敵」は
KeepDistanceを付けなければいいし、「距離を保つ敵」には付ける、という合成ベースの設計にできます。
レベルデザインの観点でも、敵の距離感をインスペクタから数値で調整できるのはかなり便利です。
「この部屋の敵はもうちょい遠めから撃ってほしいな」というときに、シーンを開いて max_distance をいじるだけで済みます。
改造案: プレイヤーの視界外では射撃しない
例えば「画面外では撃ってほしくない」「プレイヤーの方向を向いているときだけ撃ってほしい」といった制約を入れたい場合、
should_shoot() をちょっと拡張してみましょう。
func should_shoot() -> bool:
if not can_shoot:
return false
if _target == null or not is_instance_valid(_target):
return false
# まず元の距離条件をチェック
if shoot_only_in_band:
if not _is_in_distance_band():
return false
# --- ここから改造部分 ---
# 例: 画面内にいるときだけ撃つ (2D 用の簡易例)
if use_2d and owner is CanvasItem:
var viewport_rect := get_viewport().get_visible_rect()
if not viewport_rect.has_point((owner as CanvasItem).global_position):
return false
# --- 改造ここまで ---
return true
func _is_in_distance_band() -> bool:
var owner_pos
var target_pos
if use_2d:
var owner_2d := owner as Node2D
if owner_2d == null:
return false
owner_pos = owner_2d.global_position
target_pos = _target.global_position
else:
var owner_3d := owner as Node3D
if owner_3d == null:
return false
owner_pos = owner_3d.global_position
target_pos = _target.global_position
owner_pos.y = 0.0
target_pos.y = 0.0
var d := (target_pos - owner_pos).length()
var mid := (min_distance + max_distance) * 0.5
var band_half_width := (max_distance - min_distance) * 0.5 * stop_band
return d >= mid - band_half_width and d <= mid + band_half_width
このように、「距離感AI」は KeepDistance に任せつつ、
「いつ撃つか」「どこまで賢くするか」は関数を差し替えるだけでどんどん拡張できます。
継承ツリーを増やすのではなく、小さなコンポーネントを組み合わせて敵AIを作るスタイルに慣れていくと、
あとからの調整やデバッグがかなり楽になりますよ。
