Godot 4で「固定砲台」を作ろうとすると、ついこういう構成をしがちですよね。
- Playerを継承した
TurretPlayerを作る - Enemyを継承した
TurretEnemyを作る - シーンごとに「砲台専用ロジック」をベタ書きする
さらに、
- 砲台の根本ノードにスクリプト
- 銃身ノードに別スクリプト
- ターゲット探知用のエリアにまた別スクリプト
……と、ノード階層もスクリプトもどんどん増えていって、「あの砲台ロジックどこだっけ?」となりがちです。
継承ベースで TurretBase を作っても、
- 敵用砲台
- 味方用砲台
- ボスにくっつける砲台
などバリエーションが増えるたびにクラスが増殖してしまいます。
そこで今回は、「固定砲台としてターゲットを追尾する」という機能だけを独立させたコンポーネント 「TurretAI」 を作ってみましょう。
どんなノード構成にも ポン付けで使える、コンポーネント指向な砲台AIコンポーネントです。
【Godot 4】狙った獲物は逃さない!「TurretAI」コンポーネント
今回の TurretAI コンポーネントは、こんなことをしてくれます。
- 移動は一切しない「固定砲台」想定
- 指定したターゲット(またはグループ)を自動で探す
- 射程距離内にいるターゲットに対して、銃口(任意のノード)を回転させて向け続ける
- 回転速度や射程距離、どのノードを「銃身」とみなすかをエディタから設定可能
継承は一切不要で、Node2D や CharacterBody2D など「砲台の土台」になっているノードに 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)
使い方の手順
ここからは、実際にシーンへ組み込む手順を見ていきましょう。
① スクリプトを用意する
res://components/TurretAI.gdなど、好きな場所に上記コードを保存します。- Godotエディタを再読み込みすると、ノード追加ダイアログで
TurretAIが選べるようになります。
② 砲台シーンにコンポーネントとして追加する
例1: 敵の固定砲台
EnemyTurret (Node2D or CharacterBody2D) ├── Sprite2D # 砲台の土台 ├── Barrel (Sprite2D) # 銃身。回転させたい部分 └── TurretAI (Node2D) # 今回のコンポーネント
EnemyTurretは土台。位置だけを持たせて、移動しない固定砲台とします。Barrelは銃身スプライト。ここがクルッと回る想定です。TurretAIはEnemyTurretの子として追加します。
TurretAI のインスペクタ設定例:
barrel_node_path:../Barrel(親のEnemyTurretから見たパス)target_group:"player"pick_closest_target:truemax_range:500.0rotation_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) └── ...(その他ボス用ノード)
- ゲーム開始時に
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_pathとtarget_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 コンポーネントの紹介でした。
ぜひ自分のプロジェクトでも、「継承より合成」で砲台たちを量産してみてください。
