敵AIやタレットを作るとき、Godot標準のサンプルだと「ターゲットの現在位置をそのまま狙う」ことが多いですよね。でも実際のゲームでは、ターゲットは常に動いていて、弾速にも限界があります。
その結果…

  • 速いプレイヤーには一生当たらない
  • 弾速を不自然に速くしてごまかす
  • 敵ごとにスクリプトをコピペして「偏差射撃もどき」を書き散らかす

みたいなことになりがちです。

さらに、EnemyA.gdEnemyB.gd で「微妙に違う偏差射撃ロジック」が乱立し始めると、調整が本当に地獄ですね。
そこで今回は、「どのノードにもポン付けできる偏差射撃コンポーネント」として PredictAim を用意しました。

継承で「EnemyBase.gd に撃ち方を全部書く」スタイルではなく、
「撃ち方」だけを独立したコンポーネントにして、必要なノードにアタッチする 方式です。これで、プレイヤーが使うスマートなオートエイムも、敵タレットの偏差射撃も、同じコンポーネントで扱えるようになります。

【Godot 4】未来位置を読んで当てる偏差射撃!「PredictAim」コンポーネント

このコンポーネントは、

  • ターゲットの現在位置
  • ターゲットの現在速度(velocity 等)
  • 自分の弾速(またはレーザーの到達速度)

を元に、「何秒後にどこを狙えば当たるか」 を計算してくれます。
実際には、「弾が飛んでいく時間」と「ターゲットの移動」を同時に解く、簡易的な物理計算をしています。


フルコード:PredictAim.gd


extends Node
class_name PredictAim
## PredictAim (偏差射撃) コンポーネント
##
## 任意のノードにアタッチして使える「未来位置を狙うためのユーティリティ」です。
## 主な機能:
## - ターゲットの現在速度を考慮した「未来位置」を計算
## - その未来位置に向かう正規化方向ベクトルを返す
## - 2D / 3D のどちらにも対応(position / global_position, velocity / linear_velocity など)
##
## 想定ユースケース:
## - 敵タレットがプレイヤーを偏差射撃する
## - プレイヤーのオートエイムが敵の移動を予測する
## - ミサイルやホーミング弾の誘導先を計算する

@export_group("基本設定")
@export var projectile_speed: float = 400.0:
	set(value):
		projectile_speed = max(value, 1.0) # 0やマイナスだと計算不能なのでガードする

## 未来位置の計算に使う最大予測時間(秒)
## 高すぎると「とんでもなく遠く」を狙うことがあるので、ほどほどに。
@export var max_prediction_time: float = 3.0

## 2D用か3D用かのヒント。自動判定もするが、明示したいときに使う。
@export_enum("Auto", "2D", "3D")
var space_hint: int = 0

@export_group("ターゲット取得")
## ターゲットノードを直接指定する場合。
## 例: エディタ上で Player ノードをドラッグ&ドロップして設定。
@export var target_node: NodePath

## ターゲットの速度をどこから取得するかを指定します。
## - 空文字: velocity / linear_velocity / get_velocity() を自動で探す
## - "velocity": Node.velocity を使う (CharacterBody2D など)
## - "linear_velocity": Node.linear_velocity を使う (RigidBody系)
## - "custom_speed": 独自に速度を渡したいときは get_predicted_direction_with_custom_velocity() を使用
@export var velocity_property_name: StringName = &""

@export_group("デバッグ")
## 未来位置をエディタ上で可視化するかどうか(2D専用の簡易デバッグ)
@export var debug_draw: bool = false
@export var debug_color: Color = Color.CYAN
@export var debug_radius: float = 6.0

# 内部キャッシュ
var _is_2d: bool = true

func _ready() -> void:
	# 2D/3D の自動判定
	_detect_space()
	if target_node != NodePath():
		var t := get_node_or_null(target_node)
		if t == null:
			push_warning("PredictAim: target_node が見つかりません: %s" % target_node)

func _detect_space() -> void:
	if space_hint == 1:
		_is_2d = true
		return
	if space_hint == 2:
		_is_2d = false
		return
	
	# Auto の場合、親ノードの型から推測
	var p := get_parent()
	if p is Node2D or p is CharacterBody2D or p is RigidBody2D:
		_is_2d = true
	elif p is Node3D or p is CharacterBody3D or p is RigidBody3D:
		_is_2d = false
	else:
		# デフォルトは 2D として扱う
		_is_2d = true

func _process(_delta: float) -> void:
	if debug_draw:
		queue_redraw()

func _draw() -> void:
	if not debug_draw:
		return
	
	var target := _get_target()
	if target == null:
		return
	
	var self_pos := _get_global_position(self)
	var target_pos := _get_global_position(target)
	var target_vel := _get_target_velocity(target)
	
	var predicted_pos := _compute_predicted_position(self_pos, target_pos, target_vel)
	if predicted_pos == null:
		return
	
	if _is_2d:
		# 2D のみ簡易的な可視化
		var p2 := predicted_pos as Vector2
		draw_circle(p2, debug_radius, debug_color)
		draw_line(self_pos, p2, debug_color, 1.5)

# ==========================
# 公開API
# ==========================

## ターゲットを手動で設定したい場合に使う。
func set_target(node: Node) -> void:
	target_node = node.get_path()

## 現在のターゲットノードを返す(存在しない場合は null)
func get_target() -> Node:
	return _get_target()

## 偏差射撃用の「発射方向」を取得する(ターゲットの velocity を自動取得)
##
## 引数:
## - origin_global_position: 発射地点(ワールド座標)
##
## 戻り値:
## - 正規化された方向ベクトル (Vector2 / Vector3)
## - 計算不能な場合(ターゲットがいない、弾速が遅すぎる等)は、ターゲットの現在位置方向を返す
func get_predicted_direction(origin_global_position) -> Variant:
	var target := _get_target()
	if target == null:
		return Vector2.ZERO if _is_2d else Vector3.ZERO
	
	var target_pos := _get_global_position(target)
	var target_vel := _get_target_velocity(target)
	
	var predicted_pos := _compute_predicted_position(origin_global_position, target_pos, target_vel)
	if predicted_pos == null:
		# 偏差計算ができなかった場合は、現在位置を狙う
		return (target_pos - origin_global_position).normalized()
	
	return (predicted_pos - origin_global_position).normalized()

## ターゲットの速度を外部から明示的に渡したい場合はこちら。
## 例: サーバー同期された速度や、NavAgent2D の想定速度などを使いたいとき。
func get_predicted_direction_with_custom_velocity(origin_global_position, target_global_position, target_velocity) -> Variant:
	var predicted_pos := _compute_predicted_position(origin_global_position, target_global_position, target_velocity)
	if predicted_pos == null:
		return (target_global_position - origin_global_position).normalized()
	return (predicted_pos - origin_global_position).normalized()

# ==========================
# 内部ヘルパー
# ==========================

func _get_target() -> Node:
	if target_node == NodePath():
		return null
	return get_node_or_null(target_node)

func _get_global_position(node: Node) -> Variant:
	if _is_2d:
		if node is Node2D:
			return (node as Node2D).global_position
	# 3D
	if node is Node3D:
		return (node as Node3D).global_position
	# それ以外は position / global_position を頑張って探す
	if node.has_method("get_global_position"):
		return node.call("get_global_position")
	if node.has_method("global_position"):
		return node.call("global_position")
	push_warning("PredictAim: %s から global_position を取得できませんでした。" % node.name)
	return _is_2d ? Vector2.ZERO : Vector3.ZERO

func _get_target_velocity(target: Node) -> Variant:
	# 明示的なプロパティ名が指定されている場合
	if velocity_property_name != StringName(""):
		if target.has_variable(velocity_property_name):
			return target.get(velocity_property_name)
		if target.has_method(velocity_property_name):
			return target.call(velocity_property_name)
		push_warning("PredictAim: ターゲット %s に %s が見つかりません。" % [target.name, velocity_property_name])
		return _is_2d ? Vector2.ZERO : Vector3.ZERO
	
	# 自動検出: よくあるパターンを順に探す
	var candidates := [
		StringName("velocity"),
		StringName("linear_velocity"),
		StringName("get_velocity")
	]
	for name in candidates:
		if target.has_variable(name):
			return target.get(name)
		if target.has_method(name):
			return target.call(name)
	
	# 見つからなければ静止しているとみなす
	return _is_2d ? Vector2.ZERO : Vector3.ZERO

## 偏差射撃のコア計算
##
## origin: 発射位置
## target_pos: ターゲットの現在位置
## target_vel: ターゲットの現在速度
##
## 戻り値:
## - 未来位置(Vector2 / Vector3)
## - 解が存在しない場合は null
func _compute_predicted_position(origin, target_pos, target_vel) -> Variant:
	var to_target = target_pos - origin
	var v = target_vel
	var s = projectile_speed
	
	# 相対位置と相対速度を使って、 |to_target + v * t| = s * t を解く
	# 展開すると二次方程式: (v·v - s^2)t^2 + 2(to_target·v)t + (to_target·to_target) = 0
	var a = v.dot(v) - s * s
	var b = 2.0 * to_target.dot(v)
	var c = to_target.dot(to_target)
	
	var t: float = 0.0
	
	if abs(a) < 0.0001:
		# a ≒ 0 の場合、一次方程式として扱う: b t + c = 0
		if abs(b) < 0.0001:
			# ターゲットがほぼ静止 or 原点一致 → 現在位置を狙う
			return null
		t = -c / b
	else:
		var discriminant = b * b - 4.0 * a * c
		if discriminant < 0.0:
			# 解なし → 偏差射撃できない
			return null
		var sqrt_disc = sqrt(discriminant)
		
		# 2つの解のうち、正で最小のものを採用
		var t1 = (-b + sqrt_disc) / (2.0 * a)
		var t2 = (-b - sqrt_disc) / (2.0 * a)
		
		var t_candidates = []
		if t1 > 0.0:
			t_candidates.append(t1)
		if t2 > 0.0:
			t_candidates.append(t2)
		
		if t_candidates.is_empty():
			# 未来に解がない
			return null
		
		t = min(t_candidates)
	
	# 予測時間が長すぎる場合は制限する
	if t > max_prediction_time:
		t = max_prediction_time
	
	if t <= 0.0:
		return null
	
	return target_pos + v * t

使い方の手順

ここからは、2Dの敵タレットがプレイヤーを偏差射撃する例で説明します。3Dでも考え方は同じです。

手順①:コンポーネントをシーンに追加する

まずは PredictAim.gd をプロジェクトに保存します。
(例: res://components/PredictAim.gd

次に、敵タレットのシーンにこのコンポーネントをアタッチします。

Turret (Node2D)
 ├── Sprite2D
 ├── Muzzle (Marker2D)          ← 弾の発射位置
 ├── CollisionShape2D
 └── PredictAim (Node)          ← 今回のコンポーネント
  • Turret(親ノード)は、回転したり向きを変えたりするノード
  • Muzzle は銃口の位置(発射地点)
  • PredictAimNode として子に追加し、スクリプトを PredictAim.gd に設定

エディタのインスペクタで、PredictAim

  • projectile_speed に「弾の速度(pixel/sec)」
  • target_node にプレイヤーの NodePath
  • velocity_property_name"velocity"(プレイヤーが CharacterBody2D の場合)

を設定しておきましょう。

手順②:タレットのスクリプトから呼び出す

タレット本体には、弾をインスタンス化して撃つだけのシンプルなスクリプトを書きます。


extends Node2D

@export var bullet_scene: PackedScene
@export var fire_interval: float = 1.0

var _fire_timer: float = 0.0
var _predict_aim: PredictAim

func _ready() -> void:
	_predict_aim = $PredictAim  # 子ノードからコンポーネントを取得

func _process(delta: float) -> void:
	_fire_timer -= delta
	if _fire_timer <= 0.0:
		_fire_timer = fire_interval
		_fire()

func _fire() -> void:
	if bullet_scene == null:
		return
	
	var muzzle := $Muzzle as Marker2D
	var origin := muzzle.global_position
	
	# 偏差射撃で「未来位置に向かう方向」を計算
	var dir: Vector2 = _predict_aim.get_predicted_direction(origin)
	if dir == Vector2.ZERO:
		return
	
	var bullet := bullet_scene.instantiate()
	get_tree().current_scene.add_child(bullet)
	bullet.global_position = origin
	
	# 弾側のスクリプトが direction を受け取る想定
	if bullet.has_variable("direction"):
		bullet.direction = dir
	elif bullet.has_method("set_direction"):
		bullet.set_direction(dir)

弾側は、例えばこんな感じのシンプルなものを想定しています:


# Bullet.gd
extends Area2D

@export var speed: float = 400.0
var direction: Vector2 = Vector2.RIGHT

func _process(delta: float) -> void:
	position += direction * speed * delta

手順③:プレイヤー側の velocity をちゃんと持たせる

PredictAim はターゲットの速度を見に行きます。
プレイヤーが CharacterBody2D で、velocity をちゃんと更新していれば、そのまま使えます。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── (任意の他コンポーネント)

典型的なプレイヤースクリプト:


extends CharacterBody2D

@export var move_speed: float = 200.0

func _physics_process(delta: float) -> void:
	var input_dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	velocity = input_dir * move_speed
	move_and_slide()

このように velocity を更新しておけば、PredictAim は自動的に velocity を見つけてくれます。
もし別名のプロパティを使っている場合は、velocity_property_name にその名前を指定してください。

手順④:デバッグ表示で未来位置を確認する

PredictAim の debug_drawtrue にすると、2Dの場合

  • 弾を撃つ側(コンポーネントが付いているノード)から
  • 計算された「未来位置」までの線
  • 未来位置に小さな円

が描画されます。

これで、

  • 弾速が遅すぎて「とんでもなく先」を狙っていないか
  • velocity の向きと大きさが正しく設定されているか

を視覚的にチェックできます。


メリットと応用

この PredictAim コンポーネントを使うと、

  • 「偏差射撃ロジック」をすべての敵やタレットから切り離せる
  • プレイヤー/敵/動くギミックなど、どんなノードにも同じコンポーネントを再利用できる
  • 弾速や最大予測時間をインスペクタから調整できるので、レベルデザイン時の試行錯誤がラク
  • 「今は偏差射撃をオフにしたい」というときも、コンポーネントを外すか使わなければいいだけ

要するに、「撃ち方」を継承ではなく合成(コンポーネント)で表現できる のが大きなメリットですね。
敵の種類ごとに extends EnemyBase を増やすより、

  • PredictAim(偏差射撃)
  • SpreadShot(拡散弾)
  • BurstFire(バースト射撃)

といったコンポーネントを組み合わせる方が、最終的にシーン構造もスクリプトもスッキリします。

応用アイデア:偏差射撃の強さを調整する

例えば、「雑魚敵はあまり正確に偏差しないけど、ボスはガチで当ててくる」みたいな演出をしたいとき、
以下のような「わざとブレさせる」改造もできます。


## PredictAim 内に追加する改造例:
@export var aim_random_spread_deg: float = 0.0  # 偏差射撃方向にランダムなブレを足す(度)

func get_predicted_direction_with_spread(origin_global_position) -> Variant:
	var base_dir = get_predicted_direction(origin_global_position)
	if base_dir == Vector2.ZERO and _is_2d:
		return base_dir
	if not _is_2d:
		# 3Dの場合は別途実装(例えばY軸回転だけブレさせる等)
		return base_dir
	
	if aim_random_spread_deg <= 0.0:
		return base_dir
	
	var angle_rad = deg_to_rad(randf_range(-aim_random_spread_deg * 0.5, aim_random_spread_deg * 0.5))
	return base_dir.rotated(angle_rad).normalized()

こうしておけば、

  • 雑魚敵の PredictAim には aim_random_spread_deg = 20 くらい
  • ボスの PredictAim には aim_random_spread_deg = 2 くらい

といったチューニングが インスペクタから一発で できるようになります。
「偏差射撃の精度」というゲームバランスの重要パラメータも、継承ではなくコンポーネントで管理できると気持ちいいですね。

ぜひ、自分のプロジェクトの「撃ち方」をコンポーネント化して、合成スタイルのGodot開発を楽しんでいきましょう。