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))
使い方の手順
ここからは、実際にシーンへ組み込む手順を具体例付きで見ていきましょう。
例として「プレイヤーが一番近い敵に向かって自動で照準を向ける」ケースを扱います。
手順①:敵にグループを設定する
まずは「敵」側から。
- 敵シーン(例:
Enemy.tscn)を開く - ルートノード(例:
CharacterBody2D)を選択 - インスペクタの「ノード」タブ → 「グループ」 →
enemyグループを追加
シーン構成例:
Enemy (CharacterBody2D) ├── Sprite2D └── CollisionShape2D
この敵が enemy グループに入っていれば、AutoAim の target_group = "enemy" で検出されます。
手順②:プレイヤーに AutoAim コンポーネントをアタッチ
- 上のコードを
res://components/AutoAim.gdなどに保存 - プレイヤーシーン(例:
Player.tscn)を開く - ルートノード(例:
CharacterBody2D)の子として Node を追加 - その 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 をベースに少しずつ機能を足していくことで、
「継承ではなくコンポーネントの組み合わせでゲームロジックを組み立てる」スタイルに慣れていきましょう。
