Godot 4で「固定砲台」を作ろうとすると、ついこういう構成をしがちですよね。

  • Playerを継承した TurretPlayer を作る
  • Enemyを継承した TurretEnemy を作る
  • シーンごとに「砲台専用ロジック」をベタ書きする

さらに、

  • 砲台の根本ノードにスクリプト
  • 銃身ノードに別スクリプト
  • ターゲット探知用のエリアにまた別スクリプト

……と、ノード階層もスクリプトもどんどん増えていって、「あの砲台ロジックどこだっけ?」となりがちです。

継承ベースで TurretBase を作っても、

  • 敵用砲台
  • 味方用砲台
  • ボスにくっつける砲台

などバリエーションが増えるたびにクラスが増殖してしまいます。

そこで今回は、「固定砲台としてターゲットを追尾する」という機能だけを独立させたコンポーネント 「TurretAI」 を作ってみましょう。
どんなノード構成にも ポン付けで使える、コンポーネント指向な砲台AIコンポーネントです。


【Godot 4】狙った獲物は逃さない!「TurretAI」コンポーネント

今回の TurretAI コンポーネントは、こんなことをしてくれます。

  • 移動は一切しない「固定砲台」想定
  • 指定したターゲット(またはグループ)を自動で探す
  • 射程距離内にいるターゲットに対して、銃口(任意のノード)を回転させて向け続ける
  • 回転速度や射程距離、どのノードを「銃身」とみなすかをエディタから設定可能

継承は一切不要で、Node2DCharacterBody2D など「砲台の土台」になっているノードに TurretAI を子ノードとしてアタッチするだけで動きます。


フルコード: TurretAI.gd


extends Node2D
class_name TurretAI
## 固定砲台用AIコンポーネント
## - 自身は移動しない想定
## - 射程内のターゲットに銃身を向け続ける
## - ターゲットの検出方法や銃身ノードをエディタから設定可能

@export_category("Target Settings")
## 直接ターゲットを指定する場合に使う。
## プレイヤーなど単一のターゲットが決まっている時に便利。
@export var explicit_target: Node2D

## 自動検出でターゲットを探したい時のグループ名。
## 例: "player", "enemy" など。空文字ならグループ検索を行わない。
@export var target_group: StringName = &""

## グループ検索する際に、最も近いターゲットを選ぶかどうか。
## true: 射程内の中で最も近いターゲットを選択
## false: 最初に見つかったターゲットをそのまま使う
@export var pick_closest_target: bool = true

@export_category("Turret Settings")
## 銃身(回転させたいノード)へのパス。
## 砲台の土台ノードとは別に、回転させたい Sprite2D や Node2D を指定します。
## 空の場合は、自分自身(TurretAI ノード)を回転させます。
@export var barrel_node_path: NodePath

## ターゲットを追尾する最大射程(ピクセル)。
## 0 以下の場合は無制限射程として扱います。
@export var max_range: float = 400.0

## 砲台が向くことのできる最大回転速度(ラジアン/秒)。
## 値を大きくすると瞬時に向きます。小さくすると「ゆっくり旋回」します。
@export var rotation_speed: float = 5.0

## 砲台の「正面」がどちらを向いているか。
## 2Dでは右向き(0rad)がデフォルトですが、
## 上向きのスプライトを使う場合などは PI/2 などで補正します。
@export var forward_angle_offset: float = 0.0

@export_category("Debug")
## 射程やターゲットをエディタ上で可視化するかどうか。
@export var debug_draw: bool = true
## デバッグ描画の色
@export var debug_color: Color = Color(0.2, 0.8, 0.2, 0.7)

# 内部状態
var _barrel: Node2D
var _current_target: Node2D

func _ready() -> void:
    # 銃身ノードの取得
    if barrel_node_path != NodePath():
        var node := get_node_or_null(barrel_node_path)
        if node is Node2D:
            _barrel = node
        else:
            push_warning("TurretAI: barrel_node_path は Node2D を指す必要があります。代わりに自分自身を使用します。")
            _barrel = self
    else:
        # パス未指定なら自分自身を銃身として扱う
        _barrel = self

    # 明示的ターゲットがあればそれを優先
    _current_target = explicit_target

func _process(delta: float) -> void:
    # ターゲットを更新(死んだ / 削除された / 射程外などを考慮)
    _update_target()

    if _current_target and is_instance_valid(_current_target):
        _rotate_barrel_towards_target(_current_target.global_position, delta)

    if debug_draw:
        queue_redraw()

func _update_target() -> void:
    # explicit_target が設定されていればそれを最優先する
    if explicit_target and is_instance_valid(explicit_target):
        # 射程チェック
        if _is_in_range(explicit_target.global_position):
            _current_target = explicit_target
        else:
            _current_target = null
        return

    # グループ指定がなければ何もしない
    if target_group == &"":
        return

    # 既存ターゲットが有効かつ射程内ならそのまま
    if _current_target and is_instance_valid(_current_target):
        if _is_in_range(_current_target.global_position) and _current_target.is_in_group(target_group):
            return
        else:
            _current_target = null

    # 新しいターゲットを探す
    var candidates: Array = get_tree().get_nodes_in_group(target_group)
    if candidates.is_empty():
        return

    var turret_global_pos: Vector2 = global_position
    var best_target: Node2D = null
    var best_dist_sq := INF

    for node in candidates:
        if not (node is Node2D):
            continue

        var n2d := node as Node2D
        var dist_sq := turret_global_pos.distance_squared_to(n2d.global_position)

        # 射程チェック(0 以下なら無制限)
        if max_range > 0.0 and dist_sq > max_range * max_range:
            continue

        if not pick_closest_target:
            # 最初に見つかったものを採用
            _current_target = n2d
            return

        # 最も近いターゲットを選ぶ
        if dist_sq < best_dist_sq:
            best_dist_sq = dist_sq
            best_target = n2d

    _current_target = best_target

func _is_in_range(target_pos: Vector2) -> bool:
    if max_range <= 0.0:
        return true
    return global_position.distance_to(target_pos) <= max_range

func _rotate_barrel_towards_target(target_pos: Vector2, delta: float) -> void:
    # 銃身の現在グローバル角度と目標角度を計算
    var barrel_global_pos: Vector2 = _barrel.global_position
    var to_target: Vector2 = target_pos - barrel_global_pos
    if to_target == Vector2.ZERO:
        return

    var desired_angle: float = to_target.angle() - forward_angle_offset
    var current_angle: float = _barrel.global_rotation

    # current_angle から desired_angle へ、rotation_speed の範囲で補間
    var max_step: float = rotation_speed * delta
    var new_angle: float = lerp_angle(current_angle, desired_angle, min(1.0, max_step))

    _barrel.global_rotation = new_angle

func _draw() -> void:
    if not debug_draw:
        return

    # 射程の円を描画(ローカル座標で描く)
    if max_range > 0.0:
        draw_circle(Vector2.ZERO, max_range, debug_color * Color(1, 1, 1, 0.25))
        draw_circle(Vector2.ZERO, max_range, debug_color)

    # 現在ターゲットへのライン
    if _current_target and is_instance_valid(_current_target):
        var local_target_pos := to_local(_current_target.global_position)
        draw_line(Vector2.ZERO, local_target_pos, debug_color, 2.0)

使い方の手順

ここからは、実際にシーンへ組み込む手順を見ていきましょう。

① スクリプトを用意する

  1. res://components/TurretAI.gd など、好きな場所に上記コードを保存します。
  2. Godotエディタを再読み込みすると、ノード追加ダイアログで TurretAI が選べるようになります。

② 砲台シーンにコンポーネントとして追加する

例1: 敵の固定砲台

EnemyTurret (Node2D or CharacterBody2D)
 ├── Sprite2D          # 砲台の土台
 ├── Barrel (Sprite2D) # 銃身。回転させたい部分
 └── TurretAI (Node2D) # 今回のコンポーネント
  • EnemyTurret は土台。位置だけを持たせて、移動しない固定砲台とします。
  • Barrel は銃身スプライト。ここがクルッと回る想定です。
  • TurretAIEnemyTurret の子として追加します。

TurretAI のインスペクタ設定例:

  • barrel_node_path: ../Barrel(親の EnemyTurret から見たパス)
  • target_group: "player"
  • pick_closest_target: true
  • max_range: 500.0
  • rotation_speed: 6.0(速めに追尾)
  • forward_angle_offset: 砲身スプライトが「右」を向いているなら 0
    「上」を向いているテクスチャなら PI / 2 などを設定。

プレイヤーシーン側では、Rootノードに player グループを付けるのを忘れないようにしましょう。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D

Player のインスペクタ → Node タブ → Groups で player を追加します。

③ 単一ターゲットに向けたい場合(ボスの肩に付いた砲台など)

「特定のノードだけを常に狙ってほしい」場合は、explicit_target を使います。

例2: ボスの肩に付いた固定砲台が、常にプレイヤーだけを狙うケース。

Boss (CharacterBody2D)
 ├── Sprite2D
 ├── ShoulderTurret (Node2D)
 │    ├── Barrel (Sprite2D)
 │    └── TurretAI (Node2D)
 └── ...(その他ボス用ノード)
  1. ゲーム開始時に TurretAI.explicit_target に Player ノードを代入します。

# 例: GameManager.gd など
@onready var player: Node2D = $Player
@onready var boss: Node2D = $Boss

func _ready() -> void:
    var turret_ai := boss.get_node("ShoulderTurret/TurretAI") as TurretAI
    turret_ai.explicit_target = player

この場合、target_group は空のままでOKです。
explicit_target が優先されるので、プレイヤーさえ生きていれば必ずプレイヤーを追い続けます。

④ 動く床や乗り物に固定砲台を載せる

砲台が「固定」とはいえ、土台ごと動くケース(動く床や戦車の上の砲台など)もありますよね。
この場合も、コンポーネント指向なら構造はシンプルです。

例3: 移動するプラットフォームに載った固定砲台

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── TurretBase (Node2D)
 │    ├── Barrel (Sprite2D)
 │    └── TurretAI (Node2D)
 └── PlatformMover (スクリプト) # 床を動かすだけのコンポーネント
  • PlatformMover は床を左右に動かすだけのコンポーネント。
  • TurretAI はターゲット追尾だけを担当。

どちらも完全に独立しているので、砲台を消しても床は動くし、床を変えても砲台はそのまま使い回せる、きれいな合成(Composition)構造になります。


メリットと応用

この TurretAI コンポーネントを使うメリットを整理しましょう。

  • 継承地獄からの脱出
    EnemyTurret, BossTurret, PlayerTurret など、砲台ごとにクラスを増やす必要がありません。
    「砲台としての振る舞い」は TurretAI に閉じ込めておき、土台ノードは何でもOKという設計にできます。
  • ノード階層がスッキリ
    砲台ロジックはすべて TurretAI に集約されるので、土台や銃身のノードにはスクリプトが不要になります。
    「どこに何が書いてあるか」が明確になり、シーンを開いた時に迷子になりにくいです。
  • レベルデザインが楽になる
    砲台を追加したい場所に TurretAI ノードをペタッと貼り付け、barrel_node_pathtarget_group を設定するだけでOK。
    スクリプトのコピペや継承クラスの作成は不要です。
  • 「土台の挙動」と「砲台の挙動」を完全に分離
    土台は CharacterBody2D でも Node2D でも RigidBody2D でも構いません。
    動く床・戦車・ボス・プレイヤーの肩など、どこにでも同じ TurretAI を載せられます。

簡単な改造案: 射程内にターゲットがいるかどうかを返すAPI

ゲーム側から「今この砲台はターゲットを捉えているか?」を知りたい場面はよくあります。
例えば、ターゲットを捉えている時だけ発射するなどですね。

そんな時のために、TurretAI に小さなAPIを追加してみましょう。


## 現在有効なターゲットを返す。いなければ null。
func get_current_target() -> Node2D:
    if _current_target and is_instance_valid(_current_target):
        return _current_target
    return null

## 射程内にターゲットがいて、かつ銃身がほぼ向いているかどうかを返す。
## angle_tolerance は許容誤差(ラジアン)。
func is_aiming_at_target(angle_tolerance: float = 0.1) -> bool:
    var target := get_current_target()
    if not target:
        return false

    var barrel_pos := _barrel.global_position
    var to_target := target.global_position - barrel_pos
    if to_target == Vector2.ZERO:
        return true

    var desired_angle := to_target.angle() - forward_angle_offset
    var angle_diff := abs(wrapf(desired_angle - _barrel.global_rotation, -PI, PI))
    return angle_diff <= angle_tolerance

これを使えば、例えば砲台の発射スクリプト側で


if turret_ai.is_aiming_at_target():
    shoot()

のように、「きちんと狙いが付いている時だけ撃つ」といった制御が簡単に書けます。
このように、小さなコンポーネントに小さなAPIを足していくスタイルだと、継承よりもずっと柔軟にゲームロジックを組み立てられますね。


以上、「固定砲台」をどこにでもポン付けできる TurretAI コンポーネントの紹介でした。
ぜひ自分のプロジェクトでも、「継承より合成」で砲台たちを量産してみてください。