Godot 4 で弾(ミサイル)を作るとき、ついこんな構成になりがちですよね。
HomingMissile (Area2D) ← PlayerBullet を継承 ├── Sprite2D ├── CollisionShape2D └── スクリプト (ターゲット探索 + 旋回処理 + ヒット処理 + エフェクト…)
さらに敵用の誘導弾、ボス用の高性能誘導弾…と増えていくと、
- 継承ツリーがどんどん深くなる
- 「ちょっとだけ違う動き」を追加したいだけなのに、親クラスをいじる必要がある
- プレイヤー弾と敵弾で処理を共有しづらい
こうなると、弾ロジックがあちこちに散らばってメンテがつらくなります。
そこで今回は、「誘導する」という機能だけをコンポーネント化して、どんな弾ノードにもポン付けできる HomingMissile コンポーネント を用意してみましょう。
ミサイル本体は「ただの移動する弾」、誘導ロジックは「HomingMissile コンポーネント」に分離することで、
- プレイヤー弾でも敵弾でも同じコンポーネントを使い回せる
- ノード階層を深くしなくて済む
- 「誘導しない弾」を作るときはコンポーネントを外すだけ
という、合成(Composition)らしいスッキリ構成が実現できます。
【Godot 4】なめらか旋回で追尾する!「HomingMissile」コンポーネント
今回のコンポーネントは、
- ターゲットへの方向ベクトルを計算
- 現在の進行方向との角度差を求める
- 指定した「最大旋回角速度」の範囲で少しずつ向きを変える
という、いわゆる「ホーミングミサイル」の基本ロジックを提供します。
ミサイル本体の移動は、速度ベクトルを持つスクリプト側に任せる前提です(例: velocity を持つ CharacterBody2D や、独自の「BulletMover」コンポーネントなど)。
ソースコード (Full Code)
extends Node
class_name HomingMissile
## ホーミング(誘導)ロジックを提供するコンポーネント。
## 「弾本体」のノードに子としてアタッチして使うことを想定しています。
##
## 前提:
## - 親ノードが 2D 空間に存在し、position, rotation を持っている (Node2D, Area2D, CharacterBody2D など)
## - 親ノードが「進行方向ベクトル」または「速度ベクトル」を持っていると、より自然な挙動になります。
## - 例: 親に `var velocity: Vector2` があり、_physics_process で position += velocity * delta している
@export_category("Target")
## 追尾するターゲットノード。
## 直接 Node2D を指定してもいいですし、外部から set_target() で動的に設定してもOKです。
@export var target: Node2D
## ターゲットが範囲外になったらホーミングを停止したい場合の距離。
## 0 の場合は距離制限なし(常に追尾)。
@export var max_homing_distance: float = 0.0
@export_category("Turning")
## 1秒あたりの最大旋回角速度(ラジアン)。
## 値を大きくすると「キビキビ」旋回し、小さくすると「ゆっくり」旋回します。
@export_range(0.0, 20.0, 0.1, "or_greater")
var max_turn_speed: float = 5.0
## ターゲット方向との差分角がこの値(ラジアン)より小さくなったら、
## 「ほぼ向いている」とみなして旋回を止める閾値。
@export_range(0.0, 1.0, 0.01, "or_greater")
var angle_epsilon: float = 0.02
@export_category("Behavior")
## ターゲットが存在しない/範囲外になったときの挙動。
## true: そのまま直進を維持
## false: その場でホーミング処理を無効化(_physics_process の処理をスキップ)
@export var keep_straight_without_target: bool = true
## 親ノードの速度ベクトルフィールド名。
## ここに指定された名前の変数を親から探し、進行方向を更新します。
## 例: "velocity", "linear_velocity" など。空文字の場合は速度更新を行いません。
@export var parent_velocity_property: StringName = "velocity"
## 速度ベクトルの大きさを維持するかどうか。
## true: 進行方向だけを変え、speed は変えない(自然な誘導ミサイル向け)。
## false: 速度ベクトルには触れない(rotation だけ変更したい場合など)。
@export var keep_speed_magnitude: bool = true
## デバッグ用: ターゲット方向の線を描画するかどうか。
@export var debug_draw: bool = false
var _parent_node2d: Node2D
func _ready() -> void:
# 親ノードをキャッシュしておく
_parent_node2d = get_parent() as Node2D
if _parent_node2d == null:
push_warning("HomingMissile: 親ノードが Node2D 系ではありません。position / rotation を扱えないため、正常に動作しません。")
set_process(false)
set_physics_process(true)
func set_target(new_target: Node2D) -> void:
## 外部からターゲットを差し替えるためのヘルパー。
target = new_target
func _physics_process(delta: float) -> void:
if _parent_node2d == null:
return
if target == null or not is_instance_valid(target):
# ターゲットがいない場合の挙動
if keep_straight_without_target:
return
else:
# ホーミング停止
return
# ターゲットとの距離チェック(必要なら)
var to_target: Vector2 = target.global_position - _parent_node2d.global_position
var distance_to_target := to_target.length()
if max_homing_distance > 0.0 and distance_to_target > max_homing_distance:
# 範囲外
if keep_straight_without_target:
return
else:
return
if distance_to_target == 0.0:
# 同一座標なら旋回不要
return
# 現在の向き(親ノードの rotation)と、ターゲット方向の角度を取得
var current_angle: float = _parent_node2d.global_rotation
var desired_angle: float = to_target.angle()
# 角度差を [-PI, PI] の範囲に正規化
var angle_diff: float = wrapf(desired_angle - current_angle, -PI, PI)
# 角度差が十分小さいなら、ほぼ向いているとみなして終了
if abs(angle_diff) < angle_epsilon:
_parent_node2d.global_rotation = desired_angle
_update_parent_velocity()
return
# 1フレームあたりの最大旋回角
var max_step: float = max_turn_speed * delta
# 実際に回す角度。angle_diff が max_step より大きければ max_step 分だけ回す。
var step: float = clamp(angle_diff, -max_step, max_step)
_parent_node2d.global_rotation += step
_update_parent_velocity()
if debug_draw:
update()
func _update_parent_velocity() -> void:
## 親ノードが指定したプロパティ名の速度ベクトルを持っている場合、
## その向きを親の rotation に合わせて回転させます。
if parent_velocity_property == StringName(""):
return
if _parent_node2d == null:
return
if not _parent_node2d.has_variable(parent_velocity_property) and not _parent_node2d.has_method("get"):
# velocity を持っていない場合は何もしない
return
var v = _parent_node2d.get(parent_velocity_property)
if typeof(v) != TYPE_VECTOR2:
return
if keep_speed_magnitude:
var speed := v.length()
if speed > 0.0:
# 親の rotation 方向へ向け直す
var dir := Vector2.RIGHT.rotated(_parent_node2d.global_rotation)
_parent_node2d.set(parent_velocity_property, dir * speed)
else:
# 速度ベクトルには触れない
pass
func _draw() -> void:
if not debug_draw:
return
if _parent_node2d == null:
return
if target == null or not is_instance_valid(target):
return
# 親ノードのローカル座標系でターゲットへの線を描画
var from: Vector2 = Vector2.ZERO
var to: Vector2 = target.global_position - _parent_node2d.global_position
draw_line(from, to, Color.RED, 1.0)
使い方の手順
ここでは、典型的な「敵が撃つ誘導ミサイル」の例で説明します。
シーン構成例
EnemyMissile (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── HomingMissile (Node) ← 今回のコンポーネント
敵本体は別シーンとして、ミサイルをインスタンスして撃つ想定です。
① ミサイル本体のスクリプトを用意する
まずは「まっすぐ飛ぶだけの弾」を用意します。EnemyMissile.gd の例:
extends CharacterBody2D
@export var speed: float = 400.0
## HomingMissile が参照する速度ベクトル
var velocity: Vector2 = Vector2.ZERO
func _ready() -> void:
# 初期進行方向は右向き(ローカルX軸)とし、その方向に速度を設定
velocity = Vector2.RIGHT.rotated(rotation) * speed
func _physics_process(delta: float) -> void:
# 位置更新は単純に velocity に任せる
position += velocity * delta
# 画面外で自滅するなどの処理もここで行う
この時点では、まだ誘導はしません。常にまっすぐ飛ぶ弾です。
② HomingMissile コンポーネントをアタッチする
- シーンツリーで
EnemyMissile (CharacterBody2D)を選択 - 右クリック → 「子ノードを追加」 →
Nodeを追加 - 追加した Node に上記
HomingMissile.gdをアタッチ - インスペクターで以下を設定
- max_turn_speed: 5.0〜10.0 あたりで調整
- parent_velocity_property:
"velocity"(EnemyMissile.gd の変数名) - keep_speed_magnitude: ON(true)
これで、HomingMissile コンポーネント側が EnemyMissile.velocity の向きだけを、親ノードの rotation に合わせて更新してくれるようになります。
③ 発射時にターゲットをセットする
敵本体のスクリプトから、ミサイルをインスタンスしてターゲットを渡します。
敵シーン構成の例:
Enemy (Node2D) ├── Sprite2D └── (その他)
敵スクリプトの例:
extends Node2D
@export var missile_scene: PackedScene
@export var player: Node2D
func shoot() -> void:
var missile := missile_scene.instantiate()
# 自分の位置から発射
missile.global_position = global_position
missile.global_rotation = (player.global_position - global_position).angle()
# シーンに追加
get_tree().current_scene.add_child(missile)
# HomingMissile コンポーネントを探してターゲットをセット
var homing := missile.get_node_or_null("HomingMissile") as HomingMissile
if homing:
homing.set_target(player)
これで、ミサイルはプレイヤーを向いた初期角度で発射され、その後は HomingMissile コンポーネントが角度差を計算しながら徐々に旋回して追いかけてくれます。
④ 動く床やタレットにも流用する
同じコンポーネントを、別の用途にもそのまま使えます。
- タレットが撃つ誘導弾
– タレットシーンにmissile_sceneとtargetを export しておき、発射時にHomingMissile.set_target()を呼ぶだけ。 - プレイヤーの特殊弾
– プレイヤー弾シーンにも同じように HomingMissile を子として付け、ターゲットを「一番近い敵」にするなどのロジックを組む。
プレイヤー弾のシーン構成例:
PlayerMissile (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── HomingMissile (Node)
プレイヤー側からは「誰をターゲットにするか」だけを決めればよく、誘導ロジックは全部コンポーネント任せでOKです。
メリットと応用
この HomingMissile コンポーネントを使うことで、
- 弾ごとに継承クラスを増やさなくてよい
– 「誘導する/しない」はコンポーネントの有無で切り替え。 - 弾本体のスクリプトがシンプルになる
– 位置更新やヒット処理に集中できる。角度計算やターゲット管理は HomingMissile に任せる。 - 複数の弾タイプでロジックを共有できる
– 敵弾・プレイヤー弾・ギミック用ミサイルなど、すべて同じコンポーネントを再利用。 - レベルデザイン時のパラメータ調整が楽
–max_turn_speedやmax_homing_distanceをいじるだけで、簡単に「しつこいミサイル」「鈍いミサイル」を作り分け可能。
ノード構造も「弾本体 + コンポーネント」というフラットな形に保てるので、深い継承ツリーや複雑なノード階層に悩まされずに済みます。まさに「継承より合成」ですね。
改造案: ターゲットを自動で探すホーミング
固定のターゲットではなく、「一定範囲内でもっとも近い敵」を自動で追うようにしたい場合は、こんな関数を追加すると便利です。
func acquire_nearest_target(group_name: String, max_distance: float = 0.0) -> void:
## 指定グループ内の Node2D から、もっとも近いものをターゲットにする。
## max_distance > 0 の場合、その距離以内のものだけを候補にする。
if _parent_node2d == null:
return
var nearest: Node2D = null
var nearest_dist_sq := INF
for node in get_tree().get_nodes_in_group(group_name):
var n2d := node as Node2D
if n2d == null:
continue
var d_sq := _parent_node2d.global_position.distance_squared_to(n2d.global_position)
if max_distance > 0.0 and d_sq > max_distance * max_distance:
continue
if d_sq < nearest_dist_sq:
nearest_dist_sq = d_sq
nearest = n2d
if nearest != null:
set_target(nearest)
敵に "enemy" グループを付けておけば、ミサイル側から
homing.acquire_nearest_target("enemy", 800.0)
と呼ぶだけで、「近くの敵を自動追尾するプレイヤー弾」が完成します。
このように、コンポーネントを拡張していくスタイルだと、ゲーム全体の設計もだいぶ楽になりますね。




