敵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 を組み込む手順を見ていきましょう。

① コンポーネントスクリプトを用意する

  1. 上記の FlankTactic.gd を新規スクリプトとして保存します(例: res://components/FlankTactic.gd)。
  2. Godot エディタの「プロジェクト > プロジェクト設定 > スクリプトクラス」を開くと、FlankTactic がクラスとして認識されているはずです。

② 敵シーンにコンポーネントをアタッチする

例として、2Dの敵キャラを考えます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FlankTactic (Node)  ← このノードに FlankTactic.gd をアタッチ
  1. Enemy シーンを開く。
  2. Enemy の子として Node を追加し、名前を FlankTactic に変更。
  3. そのノードに FlankTactic.gd をアタッチ。
  4. インスペクタで 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 は左右に往復するだけのシンプルなスクリプト。
  • EnemyFlankTactic によって、プレイヤーの周りをぐるっと回り込みながら距離を詰める。

このとき FlankTactictarget_path../Player にしておけば、MainScene 側でプレイヤーを差し替えても、敵のAIロジックはそのまま使い回せます。
「プレイヤーの種類を増やしたい」「Coopで2人プレイにしたい」といったときも、FlankTactic 自体は変更不要で、ターゲットの指定だけ変えればOKです。


メリットと応用

この FlankTactic コンポーネントを使うことで、以下のようなメリットがあります。

  • 継承ツリーが増えない
    「回り込みできる敵用の EnemyFlanker クラス」を新たに作る必要がなく、既存の Enemy シーンにコンポーネントを追加するだけで済みます。
  • シーン構造がスッキリ
    「視界判定」「射撃AI」「回り込みAI」などをそれぞれ別コンポーネントとしてノード化できるので、1つのスクリプトが巨大になりにくいです。
  • 使い回しが簡単
    ボスの取り巻き、遠距離攻撃をする雑魚、PvP用のBotなど、いろんなキャラに同じ FlankTactic をポン付けできます。
  • デザイナーがパラメータ調整しやすい
    flank_intensitydesired_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 をガシガシ改造してみてください。