敵AIを作るとき、つい「プレイヤーに向かって look_at して真っ直ぐ突っ込む」だけの実装にしがちですよね。でもそれだと、どの敵も同じような動きになってしまって、ゲームとしてはちょっと物足りない…。
さらに、EnemyBase みたいな親クラスをどんどん肥大化させて、「この敵だけ回り込みしたい」「この敵だけ直線突撃させたい」といった要望に対応しようとすると、継承ツリーが地獄になりがちです。
そこで今回は、「プレイヤーへ直線的に向かわず、横へ移動しながら距離を詰める」ちょっと賢いAIを、コンポーネントとして後付けできるようにしてみましょう。
敵のシーン構造はそのままに、FlankTactic コンポーネントをポン付けするだけで「回り込みAI」を生やせるようにします。
【Godot 4】横から刺せ!賢い回り込みAI「FlankTactic」コンポーネント
今回の FlankTactic は以下のような動きをします。
- 指定したターゲット(プレイヤーなど)との距離を保ちながら接近
- ターゲットに対して「横方向(左右どちらか)」に動き、回り込むような軌道をとる
- 一定距離まで近づいたら「横移動を抑えて」やや正面から詰める
- ターゲットがいない場合は何もしない(他のAIに任せる)
そしてコンポーネントなので、
- 敵の移動そのもの(
velocityの適用)は親側(例:CharacterBody2Dのスクリプト)に任せる - このコンポーネントは「どっちの方向にどのくらい進みたいか」の意図ベクトルだけを出す
という役割分担にします。これが「継承より合成」のおいしいところですね。
GDScript フルコード
extends Node
class_name FlankTactic
"""
プレイヤーへ直線的に向かわず、横へ移動しながら距離を詰める「回り込み」用AIコンポーネント。
■ 想定する使い方
- 親ノードは CharacterBody2D / CharacterBody3D などの「自力で動く」ノード
- このコンポーネントは「望ましい移動方向ベクトル」を計算して公開する
- 実際の移動(velocity の更新や move_and_slide)は親のスクリプト側で行う
■ ざっくりした挙動
- ターゲットへの方向ベクトルを計算
- そのベクトルに直交する「横方向ベクトル」を混ぜることで回り込み
- 近距離では横成分を弱め、やや正面から詰める
"""
@export var target_path: NodePath
## 追いかけたいターゲット(通常はプレイヤー)の NodePath。
## 空の場合、自動でシーンツリーから "Player" などを探す簡易機能を持たせてもよいですが、
## 明示的にシーン側で設定するのを推奨します。
@export_range(0.0, 2000.0, 1.0)
var desired_distance: float = 160.0
## ターゲットとこの距離くらいを保ちながら回り込みたい「理想距離」。
## 近づきすぎたら少し離れようとし、遠すぎたら近づこうとする。
@export_range(0.0, 1.0, 0.01)
var flank_intensity: float = 0.7
## どれだけ「横方向」を優先するか(0 = まっすぐ、1 = ほぼ真横)。
## 高いほど「大きく弧を描いて回り込む」ような動きになる。
@export_range(0.0, 1.0, 0.01)
var close_range_flank_damp: float = 0.3
## 近距離でどれくらい「横成分」を弱めるか。
## 1.0 に近いほど、接近時に横移動がほとんど消えて正面から詰める感じになる。
@export_range(0.0, 1000.0, 1.0)
var min_engage_distance: float = 48.0
## この距離より内側では「ほぼ接触状態」とみなして、前進を抑えめにする。
## 近接攻撃などと組み合わせるときに使えます。
@export_range(0.0, 1000.0, 1.0)
var max_engage_distance: float = 600.0
## この距離より外側だと「とにかく近づく」ことを優先し、横成分をやや抑える。
@export_range(0.0, 1.0, 0.01)
var target_lock_smoothness: float = 0.15
## ターゲットの動きに対して、どれくらい「滑らかに方向を変えるか」。
## 0 に近いほどカクカク、1 に近いほどターゲットの方向変化に即座に追従。
@export_enum("Auto", "Clockwise", "CounterClockwise")
var flank_side: String = "Auto"
## どちら側へ回り込むかの指定。
## - Auto: ターゲットとの相対位置などから自動で決定(フレームごとに変化することも)
## - Clockwise: 常に時計回り(右側)へ回り込む
## - CounterClockwise: 常に反時計回り(左側)へ回り込む
@export_range(0.0, 1.0, 0.01)
var auto_side_bias: float = 0.5
## flank_side == "Auto" のときにどちら側を選びやすくするかのバイアス。
## 0.5 で完全ランダム、0 に近いと左寄り、1 に近いと右寄り。
@export var debug_draw: bool = false
## true にすると、回り込み方向などを簡易的に描画してデバッグできる(2D前提)。
## 計算された「望ましい移動方向ベクトル」(正規化済み)。
## 親側はこれに移動速度を掛けて velocity を決める想定。
var desired_direction: Vector2 = Vector2.ZERO
## 内部用: ターゲットへのスムージングされた方向
var _smoothed_dir_to_target: Vector2 = Vector2.ZERO
## 内部用: 実際のターゲットノード
var _target: Node2D = null
func _ready() -> void:
# target_path が設定されていればそれを解決
if target_path != NodePath():
var node := get_node_or_null(target_path)
if node and node is Node2D:
_target = node
# 未設定なら、よくある "Player" 名のノードを雑に探す(任意)
if _target == null:
_target = _find_default_player()
# 初期方向は前方(右向き)としておく
_smoothed_dir_to_target = Vector2.RIGHT
func _process(delta: float) -> void:
if _target == null or not is_instance_valid(_target):
desired_direction = Vector2.ZERO
return
var self_2d := _get_global_position()
var target_2d := _target.global_position
var to_target := target_2d - self_2d
var distance := to_target.length()
if distance < 0.001:
desired_direction = Vector2.ZERO
return
var dir_to_target := to_target.normalized()
# ターゲット方向をスムージング(急激な方向転換を抑える)
_smoothed_dir_to_target = _smoothed_dir_to_target.lerp(
dir_to_target, target_lock_smoothness
).normalized()
# 距離に応じて前進・後退の強さを決める(1.0 = 近づく、-1.0 = 離れる)
var forward_factor := _compute_forward_factor(distance)
# 回り込む「横方向ベクトル」を計算
var side_dir := _compute_side_direction(_smoothed_dir_to_target)
# 距離に応じて横成分を弱めたり強めたりする
var flank_factor := _compute_flank_factor(distance)
# 実際の望ましい方向ベクトルを組み立てる
var forward_vec := _smoothed_dir_to_target * forward_factor
var flank_vec := side_dir * flank_intensity * flank_factor
var desired := forward_vec + flank_vec
if desired.length() < 0.001:
desired_direction = Vector2.ZERO
else:
desired_direction = desired.normalized()
if debug_draw:
_queue_debug_draw()
## 距離に応じて前進/後退の強さを計算
func _compute_forward_factor(distance: float) -> float:
# 距離が理想よりかなり大きい場合は前進を強める
if distance > max_engage_distance:
return 1.0
# 距離が近すぎる場合はやや後退気味にする(またはほぼ停止)
if distance < min_engage_distance:
# 0 ~ min_engage_distance の範囲を -0.2 ~ 0.2 にマップ
var t := clamp(distance / max(min_engage_distance, 0.001), 0.0, 1.0)
return lerp(-0.2, 0.2, t)
# それ以外は desired_distance に近づくように前進/後退を決める
var diff := distance - desired_distance
# diff > 0 なら前進(1 に近づく)、diff < 0 なら後退(-0.3 程度まで)
var t2 := clamp(diff / max(desired_distance, 0.001), -1.0, 1.0)
return clamp(t2, -0.3, 1.0)
## 距離に応じて横成分の強さを決める
func _compute_flank_factor(distance: float) -> float:
# 遠距離では横成分を少し抑えて、とにかく近づく
if distance > max_engage_distance:
return 0.3
# 近距離では横成分を弱めて、正面寄りに詰める
if distance < desired_distance:
# desired_distance ~ 0 の範囲で 1.0 ~ (1.0 - close_range_flank_damp) に補間
var t := clamp(distance / max(desired_distance, 0.001), 0.0, 1.0)
return lerp(1.0 - close_range_flank_damp, 1.0, t)
# 中距離ではそのまま
return 1.0
## 回り込み方向(横ベクトル)を計算
func _compute_side_direction(dir_to_target: Vector2) -> Vector2:
# dir_to_target に直交するベクトルを2つ(右・左)作る
# 右回り(時計回り): (x, y) -> (y, -x)
var right_vec := Vector2(dir_to_target.y, -dir_to_target.x)
# 左回り(反時計回り): (x, y) -> (-y, x)
var left_vec := Vector2(-dir_to_target.y, dir_to_target.x)
match flank_side:
"Clockwise":
return right_vec
"CounterClockwise":
return left_vec
_:
# Auto の場合は、簡易的にランダム or バイアス付きで選択
var r := randf()
var threshold := auto_side_bias
if r < threshold:
return right_vec
else:
return left_vec
## 2D前提で自分のグローバル座標を取得
func _get_global_position() -> Vector2:
if self is Node2D:
return (self as Node2D).global_position
# それ以外の場合は親を辿って Node2D を探す(あくまで保険)
var p := get_parent()
while p:
if p is Node2D:
return (p as Node2D).global_position
p = p.get_parent()
return Vector2.ZERO
## よくある "Player" ノードを雑に探す(任意)
func _find_default_player() -> Node2D:
var root := get_tree().get_current_scene()
if not root:
return null
# 名前で探す簡易版。プロジェクトに合わせて書き換えてOK。
var candidate_names := ["Player", "player", "Hero", "hero"]
for name in candidate_names:
var node := root.find_child(name, true, false)
if node and node is Node2D:
return node
return null
## デバッグ描画(2D想定)
func _queue_debug_draw() -> void:
if not (self is Node2D):
return
(self as Node2D).queue_redraw()
func _draw() -> void:
if not debug_draw:
return
if not (self is Node2D):
return
var origin := Vector2.ZERO
var scale := 40.0
# ターゲット方向(スムージング後): 青
draw_line(origin, _smoothed_dir_to_target * scale, Color.BLUE, 2.0)
# 望ましい移動方向: 緑
draw_line(origin, desired_direction * scale, Color.GREEN, 2.0)
# 原点
draw_circle(origin, 3.0, Color.WHITE)
使い方の手順
ここからは、実際に敵キャラへ FlankTactic を組み込む手順を見ていきましょう。
① コンポーネントスクリプトを用意する
- 上記の
FlankTactic.gdを新規スクリプトとして保存します(例:res://components/FlankTactic.gd)。 - Godot エディタの「プロジェクト > プロジェクト設定 > スクリプトクラス」を開くと、
FlankTacticがクラスとして認識されているはずです。
② 敵シーンにコンポーネントをアタッチする
例として、2Dの敵キャラを考えます。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── FlankTactic (Node) ← このノードに FlankTactic.gd をアタッチ
Enemyシーンを開く。Enemyの子としてNodeを追加し、名前をFlankTacticに変更。- そのノードに
FlankTactic.gdをアタッチ。 - インスペクタで
target_pathにプレイヤー(例:../Playerやシーンルートからのパス)を設定。
これで、「敵の中に回り込みAIが1個入った」状態になります。
③ 親(Enemy)の移動ロジックで desired_direction を使う
コンポーネントは「どっちに行きたいか」だけを出しているので、CharacterBody2D 側でそれを受け取って移動に反映します。
Enemy にアタッチするシンプルな例:
extends CharacterBody2D
@export var move_speed: float = 120.0
var flank_tactic: FlankTactic
func _ready() -> void:
# 子ノードから FlankTactic を取得
flank_tactic = $FlankTactic
func _physics_process(delta: float) -> void:
if flank_tactic:
# FlankTactic が計算した「望ましい方向」を取得
var dir := flank_tactic.desired_direction
velocity = dir * move_speed
else:
velocity = Vector2.ZERO
move_and_slide()
これだけで、FlankTactic のロジックをそのまま移動に反映できます。
別の敵では move_speed だけ変えたり、途中で FlankTactic を無効化して別AIに切り替えたり、といったことも簡単ですね。
④ 実戦的な例:プレイヤーと動く床と組み合わせる
例えば、以下のようなシーン構成を考えます。
MainScene (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ └── CollisionShape2D
├── Enemy (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── FlankTactic (Node)
└── MovingPlatform (CharacterBody2D)
├── Sprite2D
└── CollisionShape2D
Playerは普通に WASD や十字キーで動く。MovingPlatformは左右に往復するだけのシンプルなスクリプト。EnemyはFlankTacticによって、プレイヤーの周りをぐるっと回り込みながら距離を詰める。
このとき FlankTactic の target_path を ../Player にしておけば、MainScene 側でプレイヤーを差し替えても、敵のAIロジックはそのまま使い回せます。
「プレイヤーの種類を増やしたい」「Coopで2人プレイにしたい」といったときも、FlankTactic 自体は変更不要で、ターゲットの指定だけ変えればOKです。
メリットと応用
この FlankTactic コンポーネントを使うことで、以下のようなメリットがあります。
- 継承ツリーが増えない
「回り込みできる敵用のEnemyFlankerクラス」を新たに作る必要がなく、既存のEnemyシーンにコンポーネントを追加するだけで済みます。 - シーン構造がスッキリ
「視界判定」「射撃AI」「回り込みAI」などをそれぞれ別コンポーネントとしてノード化できるので、1つのスクリプトが巨大になりにくいです。 - 使い回しが簡単
ボスの取り巻き、遠距離攻撃をする雑魚、PvP用のBotなど、いろんなキャラに同じFlankTacticをポン付けできます。 - デザイナーがパラメータ調整しやすい
flank_intensityやdesired_distanceをインスペクタでいじるだけで、「ぐるぐる回る」「浅く回り込む」「ほぼ正面から詰める」などの性格を変えられます。
さらに、他のコンポーネントと組み合わせるときも、desired_direction を「重み付きでブレンドする」だけで、かなり柔軟な行動が作れます。例えば:
ChaseTargetコンポーネント(ただ追いかける)RetreatOnLowHPコンポーネント(HPが減ると逃げる)FlankTacticコンポーネント(回り込み)
これらの desired_direction を合成して、状況に応じて重みを切り替える、という設計もできます。
改造案:攻撃レンジに入ったら横移動を強める
例えば、「攻撃可能距離に入ったら、あえて正面には立たず、横方向の移動を強めてプレイヤーを翻弄する」ようにしたいとしましょう。
以下は、そのために _compute_flank_factor を少し改造した例です。
@export_range(0.0, 1000.0, 1.0)
var attack_range: float = 120.0
## この距離以内なら「攻撃レンジ」とみなす。
func _compute_flank_factor(distance: float) -> float:
# 遠距離では横成分を少し抑えて、とにかく近づく
if distance > max_engage_distance:
return 0.3
# 攻撃レンジ内では、あえて横移動を強める(最大 1.2 倍まで)
if distance
こんな感じで、「距離に応じて横成分をどう扱うか」をカスタマイズするだけで、敵の性格がガラッと変わるのが分かると思います。
継承ベースだと「また新しいサブクラスを作るか…」となりがちですが、コンポーネントなら1ファイル内の関数をちょっといじるだけでOK。
ぜひ自分のゲームに合わせて、FlankTactic をガシガシ改造してみてください。
