Godot でシューティングやアクションを作っていると、「一番近い敵の方向を知りたい」って場面、めちゃくちゃ多いですよね。
でも素直に実装しようとすると、こんな感じになりがちです。

  • プレイヤーシーンのスクリプトに「敵探索ロジック」をベタ書き
  • 敵のリストをシングルトンで管理して、そこから距離を計算
  • オブジェクトごとに「自動照準の処理」をコピペ

さらに、

  • 「このタレットは enemy グループを狙うけど、あっちは boss グループだけ狙いたい」
  • 「プレイヤーの照準は 600px だけど、タレットは 300px だけにしたい」

みたいなチューニングを始めると、継承ベースや巨大スクリプトではどんどんカオスになっていきます。
そこで登場するのが、今回の 「AutoAim」コンポーネントです。

ノード階層をムダに深くせず、「自動照準」という機能だけを 1 コンポーネントとして切り出して
プレイヤーでもタレットでも、好きなノードにポン付けできるようにしてみましょう。

【Godot 4】狙いたい相手はコンポーネントに任せよう!「AutoAim」コンポーネント

この「AutoAim」コンポーネントは、こんなことをしてくれます。

  • 指定したグループのノードをシーン全体から探索
  • 自分(AutoAim がアタッチされたノード)からの距離を計算
  • 範囲内にいる中で「最も近い敵」を選ぶ
  • その敵への方向ベクトル角度(ラジアン)をプロパティとして公開

つまり、「どこを向けばいいか」だけをコンポーネントが教えてくれるので、
発射処理や見た目の回転は、各ノード側で好きに実装できるようになります。


フルコード:AutoAim.gd


extends Node
class_name AutoAim
## AutoAim (自動照準) コンポーネント
## - 指定したグループのノードを探索し、
##   範囲内で最も近いターゲットの方向ベクトルと角度を計算します。
##
## 想定用途:
## - プレイヤーの射撃方向の自動補正
## - タレット / タワーの自動照準
## - 敵AIの「一番近いプレイヤーを狙う」処理 など

@export_group("基本設定")
## 探索対象となるグループ名。
## 例: "enemy", "boss", "player" など。
@export var target_group: StringName = &"enemy"

## 探索する最大距離(ピクセル)。
## 0 以下にすると「無制限」として扱います。
@export var max_distance: float = 600.0

## 毎秒何回ターゲット探索を行うか。
## 例: 10 にすると 0.1 秒ごとに更新。
## 高すぎるとパフォーマンスに影響する可能性があります。
@export_range(1, 60, 1, "or_greater") var update_rate: int = 10

@export_group("フィルタリング")
## 対象ノードがこのクラス(または継承クラス)である場合のみ対象にする。
## 例: CharacterBody2D, Node2D など。
## 空文字列のままだとクラスフィルタは無効。
@export var required_class_name: String = ""

## 自分自身と同じグループに属するターゲットを除外するかどうか。
## 例えば、敵タレットが「enemy」グループを狙う場合に、
## 自分自身(タレット)を誤ってロックオンしないようにするため。
@export var exclude_self_group: bool = true

@export_group("デバッグ")
## エディタ上で、現在ロックオンしているターゲットへの線を描画するかどうか。
@export var debug_draw: bool = false

## 現在ロックオンしているターゲット(なければ null)。
var current_target: Node2D = null

## ターゲットへの正規化された方向ベクトル。
## ターゲットがいない場合は Vector2.ZERO。
var aim_direction: Vector2 = Vector2.ZERO

## ターゲットへの角度(ラジアン)。右方向が 0。
## ターゲットがいない場合は 0.0。
var aim_angle: float = 0.0

## 現在のターゲットまでの距離(ピクセル)。ターゲットがいない場合は INF。
var target_distance: float = INF

## 内部用: 更新タイマー
var _time_accumulator: float = 0.0


func _ready() -> void:
    ## 2D 専用として扱うため、Node2D 以外に付いていたら警告を出します。
    if not owner is Node2D:
        push_warning("AutoAim は Node2D 系ノードにアタッチすることを推奨します。現在の owner: %s" % [owner])
    set_process(true)


func _process(delta: float) -> void:
    _time_accumulator += delta
    var interval := 1.0 / float(update_rate)
    if _time_accumulator < interval:
        return

    _time_accumulator = 0.0
    _update_target()


func _update_target() -> void:
    ## シーンツリーから対象グループのノードを全取得
    if target_group.is_empty():
        current_target = null
        aim_direction = Vector2.ZERO
        aim_angle = 0.0
        target_distance = INF
        update()
        return

    var candidates: Array = get_tree().get_nodes_in_group(target_group)

    var owner_node2d: Node2D = owner if owner is Node2D else null
    if owner_node2d == null:
        current_target = null
        aim_direction = Vector2.ZERO
        aim_angle = 0.0
        target_distance = INF
        update()
        return

    var owner_pos: Vector2 = owner_node2d.global_position
    var closest_target: Node2D = null
    var closest_dist_sq: float = INF

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

        var target := node as Node2D

        ## 自分自身を除外
        if target == owner_node2d:
            continue

        ## 同じグループを除外する設定ならスキップ
        if exclude_self_group and _shares_any_group(owner_node2d, target):
            continue

        ## クラス名フィルタ(指定されている場合)
        if required_class_name != "":
            if not _is_instance_of_class_name(target, required_class_name):
                continue

        var dist_sq := owner_pos.distance_squared_to(target.global_position)

        ## 最大距離チェック(0 以下なら無制限)
        if max_distance > 0.0 and dist_sq > max_distance * max_distance:
            continue

        if dist_sq < closest_dist_sq:
            closest_dist_sq = dist_sq
            closest_target = target

    current_target = closest_target

    if current_target:
        target_distance = sqrt(closest_dist_sq)
        aim_direction = (current_target.global_position - owner_pos).normalized()
        aim_angle = aim_direction.angle()
    else:
        target_distance = INF
        aim_direction = Vector2.ZERO
        aim_angle = 0.0

    if debug_draw:
        update()


func _shares_any_group(a: Node, b: Node) -> bool:
    ## 2つのノードが少なくとも1つ同じグループを持っているかチェック
    var groups_a := a.get_groups()
    for g in groups_a:
        if b.is_in_group(g):
            return true
    return false


func _is_instance_of_class_name(node: Object, class_name: String) -> bool:
    ## 指定クラス名またはその継承クラスかどうかを判定
    ## Godot 4 では is_class() を使える(ただし class_name の指定に注意)
    return node.is_class(class_name)


func get_aim_direction() -> Vector2:
    ## 外部から安全に方向を取得するためのアクセサ
    return aim_direction


func get_aim_angle() -> float:
    ## 外部から安全に角度を取得するためのアクセサ
    return aim_angle


func get_target() -> Node2D:
    ## 現在ロックオンしているターゲットを返します(なければ null)。
    return current_target


func has_target() -> bool:
    ## 現在有効なターゲットがいるかどうか。
    return current_target != null


func _draw() -> void:
    ## デバッグ描画: ターゲットへの線と距離を表示
    if not debug_draw:
        return

    var owner_node2d: Node2D = owner if owner is Node2D else null
    if owner_node2d == null:
        return

    if current_target:
        var local_from := Vector2.ZERO
        var local_to := owner_node2d.to_local(current_target.global_position)
        draw_line(local_from, local_to, Color.GREEN, 2.0)
        draw_circle(local_to, 4.0, Color.RED)
    else:
        ## ターゲットがいないときは、最大距離の円を描く(任意)
        if max_distance > 0.0:
            draw_circle(Vector2.ZERO, max_distance, Color(0.5, 0.5, 0.5, 0.3))

使い方の手順

ここからは、実際にシーンへ組み込む手順を具体例付きで見ていきましょう。
例として「プレイヤーが一番近い敵に向かって自動で照準を向ける」ケースを扱います。

手順①:敵にグループを設定する

まずは「敵」側から。

  1. 敵シーン(例: Enemy.tscn)を開く
  2. ルートノード(例: CharacterBody2D)を選択
  3. インスペクタの「ノード」タブ → 「グループ」 → enemy グループを追加

シーン構成例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 └── CollisionShape2D

この敵が enemy グループに入っていれば、AutoAim の target_group = "enemy" で検出されます。

手順②:プレイヤーに AutoAim コンポーネントをアタッチ

  1. 上のコードを res://components/AutoAim.gd などに保存
  2. プレイヤーシーン(例: Player.tscn)を開く
  3. ルートノード(例: CharacterBody2D)の子として Node を追加
  4. その Node に AutoAim.gd をアタッチ

シーン構成図:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── AutoAim (Node)

このように、「プレイヤー本体のスクリプト」と「自動照準のロジック」を分離できるのがポイントです。
プレイヤー側は「どこを向けばいいか」を AutoAim に聞くだけでOKです。

手順③:プレイヤーから AutoAim の情報を利用する

プレイヤーのスクリプト例(最も近い敵に向かって腕や銃を回転させたい場合):


extends CharacterBody2D

@onready var auto_aim: AutoAim = $AutoAim
@onready var gun_sprite: Node2D = $GunSprite   # 銃の見た目を表すノード

func _process(delta: float) -> void:
    # 何かキーを押している間だけ自動照準する、などの条件でもOK
    if auto_aim.has_target():
        # 方向ベクトルで向きを決める
        var dir: Vector2 = auto_aim.get_aim_direction()
        # 銃の向きをターゲット方向に回転
        gun_sprite.rotation = dir.angle()
    else:
        # ターゲットがいない場合の処理(例: 正面を向く)
        gun_sprite.rotation = 0.0

発射処理に組み込む場合は、例えばこんな感じです。


func shoot_bullet() -> void:
    if not auto_aim.has_target():
        return  # ターゲットがいないなら撃たない(好みで)

    var dir := auto_aim.get_aim_direction()
    var bullet := bullet_scene.instantiate()
    bullet.global_position = global_position
    bullet.direction = dir  # 弾側のスクリプトで受け取る想定
    get_tree().current_scene.add_child(bullet)

手順④:タレットや動く床にもそのまま再利用する

同じ AutoAim を、今度は「タレット(固定砲台)」に付けてみます。

Turret (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── AutoAim (Node)

タレットのスクリプトでは、ほぼコピペで使えます。


extends Node2D

@onready var auto_aim: AutoAim = $AutoAim

func _process(delta: float) -> void:
    if auto_aim.has_target():
        rotation = auto_aim.get_aim_angle()

func _on_shoot_timer_timeout() -> void:
    if auto_aim.has_target():
        shoot_bullet_toward(auto_aim.get_aim_direction())

「動く床が、近くの敵に向けてレーザーを出す」みたいなギミックにも、
同じコンポーネントをそのままポン付けできます。
継承ツリーを増やさずに機能だけを差し込めるのが、コンポーネント指向の気持ちいいところですね。


メリットと応用

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

  • シーン構造がシンプル
    プレイヤーもタレットも「本体 + AutoAim」というフラットな構成で済みます。
  • ロジックの再利用性が高い
    「一番近いターゲットを探す」という処理を一箇所に集約できるので、
    修正・拡張もこのコンポーネントだけ触ればOKです。
  • テストやデバッグがしやすい
    debug_draw を ON にすれば、エディタ上で「今どこを狙っているか」が一目瞭然。
    グループ設定のミスや距離設定のミスにもすぐ気づけます。
  • 継承地獄からの脱出
    「自動照準プレイヤー」「自動照準タレット」「自動照準敵」…とクラスを増やさなくて済みます。
    どれも AutoAim コンポーネントを付けるだけで同じ機能を共有できます。

応用としては、例えば:

  • プレイヤーは enemy グループを狙うが、ボスは player グループを狙う
  • 一部のタレットだけ required_class_name = "Boss" にしてボス専用にする
  • スナイパー系は max_distance を大きく、ショットガン系は小さくする

など、インスペクタからパラメータをいじるだけで挙動を変えられるのが強みです。

改造案:視野角(FOV)を追加して「前方だけ狙う」

例えば「真後ろの敵はロックオンしない」ようにしたい場合、
視野角(FOV)を追加する改造が考えられます。

AutoAim に以下のプロパティと関数を追加するイメージです。


@export_group("視野設定")
## 視野角(度)。0 の場合は無制限(全方向)。
## 例: 90 にすると、前方 90° の範囲だけロックオン対象にする。
@export_range(0, 360, 1, "or_greater") var fov_degrees: float = 0.0

## 所有者の「前方方向」を返す関数。
## デフォルトでは右方向 (1, 0) を前方とみなしますが、
## 実際には owner の向きに応じて上書きしても良いです。
func _get_forward_direction() -> Vector2:
    return Vector2.RIGHT.rotated((owner as Node2D).rotation)

## _update_target() 内のループで、距離チェックの後あたりに追加:
## if fov_degrees > 0.0:
##     var to_target := (target.global_position - owner_pos).normalized()
##     var forward := _get_forward_direction()
##     var angle_diff := abs(forward.angle_to(to_target))
##     if angle_diff > deg_to_rad(fov_degrees) * 0.5:
##         continue

こうすると「前を向いている方向の一定角度内だけをロックオン」できるようになります。
FPS っぽい照準や、視野制限のある敵AIなどにも応用できますね。

このように、AutoAim をベースに少しずつ機能を足していくことで、
「継承ではなくコンポーネントの組み合わせでゲームロジックを組み立てる」スタイルに慣れていきましょう。