Godotで敵AIを書くとき、つい「SniperEnemy.gd を継承して…」「レーザー用のノードを子に足して…」「発射エフェクト用のスクリプトも継承して…」と、どんどん深いノード階層+継承ツリーになりがちですよね。少し仕様が変わるだけで親クラスをいじる羽目になったり、似たような敵が増えるたびにスクリプトがコピペされていく…。
そこで今回は、遠距離からプレイヤーをロックオンしてレーザー照射 → 高速弾を撃つ、いわゆる「スナイパー系の敵AI」を、1つの独立コンポーネントとして実装してみましょう。
SniperAim コンポーネントを任意の敵ノードにアタッチするだけで、
- プレイヤー検知(距離・視野角・レイキャスト)
- レーザー照射(ロックオン演出)
- 高速弾の発射
までをまとめて面倒みてくれます。敵ごとにバラバラのAIスクリプトを書くのではなく、「狙撃機能」という1つの責務として合成できるのがポイントですね。
【Godot 4】遠距離からプレイヤーを狙い撃ち!「SniperAim」コンポーネント
コンポーネントのフルコード
extends Node
class_name SniperAim
## 狙撃AIコンポーネント
## 任意の 2D キャラ(敵)にアタッチして使うことを想定しています。
##
## 機能:
## - プレイヤーを一定距離内で検知
## - 視線上に障害物がなければロックオン
## - レーザーを一定時間照射
## - 照射完了後に高速弾を発射
@export_group("ターゲット設定")
## ロックオン対象のノードパス。通常は Player(CharacterBody2D など) を指定。
@export var target_path: NodePath
## どのグループのノードを「ターゲット候補」とみなすか。
## target_path が未設定の場合、このグループから一番近いノードを探します。
@export var target_group: StringName = &"player"
@export_group("射程・視界設定")
## この距離以内にターゲットがいないと何もしません。
@export var max_lock_distance: float = 800.0
## 視野角(度)。0 で直線、180 で半円、360 で全方向。
@export_range(0.0, 360.0, 1.0)
var field_of_view_deg: float = 60.0
## 視線チェックに使うコリジョンレイヤー。
## ここで指定したレイヤーにヒットしたら「遮蔽物あり」とみなします。
@export_flags_2d_physics var obstacle_collision_mask: int = 1
@export_group("レーザー演出")
## レーザーを表示する Line2D の NodePath。
## 未設定の場合、自動で Line2D を生成して子として追加します。
@export var laser_line_path: NodePath
## ロックオン中にレーザーを照射する時間(秒)。
@export var lock_on_time: float = 1.2
## レーザーの色(ロックオン中)
@export var laser_color_locked_on: Color = Color.RED
## ロックオン中にレーザーを点滅させるかどうか。
@export var laser_blink: bool = true
## 点滅の速度(1秒あたりのON/OFF回数の目安)
@export var laser_blink_frequency: float = 4.0
@export_group("弾丸関連")
## 弾丸シーンのプリロードパス。高速弾を PackedScene として指定します。
## 例: res://scenes/bullets/sniper_bullet.tscn
@export var bullet_scene: PackedScene
## 弾丸を生成する位置のオフセット(このコンポーネントの親ノード位置からの相対位置)
@export var muzzle_offset: Vector2 = Vector2(0, 0)
## 弾丸の初速度(方向はターゲット方向)
@export var bullet_speed: float = 1200.0
@export_group("挙動設定")
## 次の狙撃を行うまでのクールダウン時間(秒)
@export var cooldown_time: float = 2.5
## ターゲットが射程外に出たり、視線が通らなくなったときに
## 照射を中断するかどうか。
@export var cancel_lock_if_lost_sight: bool = true
## 常時自動で狙撃するかどうか。
## false の場合、外部から start_sniping() を呼び出すことで単発狙撃させられます。
@export var auto_sniping: bool = true
@export_group("デバッグ")
## ターゲット方向のデバッグ線を描画するかどうか。
@export var debug_draw: bool = false
## デバッグ線の色
@export var debug_color: Color = Color(0, 1, 0, 0.5)
# 内部状態管理
enum State {
IDLE,
LOCKING_ON,
COOLDOWN,
}
var _state: State = State.IDLE
var _target: Node2D
var _laser_line: Line2D
var _time_in_state: float = 0.0
var _cached_space_state: PhysicsDirectSpaceState2D
func _ready() -> void:
# ターゲットの取得
_resolve_target()
# レーザー Line2D の取得または自動生成
_setup_laser_line()
# Physics 空間の参照をキャッシュ
var world_2d := get_world_2d()
if world_2d:
_cached_space_state = world_2d.direct_space_state
# 初期状態は IDLE
_set_state(State.IDLE)
func _process(delta: float) -> void:
_time_in_state += delta
match _state:
State.IDLE:
_process_idle(delta)
State.LOCKING_ON:
_process_locking_on(delta)
State.COOLDOWN:
_process_cooldown(delta)
if debug_draw:
queue_redraw()
func _draw() -> void:
if not debug_draw:
return
if not is_instance_valid(_target):
return
var origin := _get_muzzle_global_position()
var dir := (_target.global_position - origin)
draw_line(to_local(origin), to_local(origin + dir), debug_color, 1.0)
# ==========================
# 状態ごとの処理
# ==========================
func _process_idle(delta: float) -> void:
if not auto_sniping:
return
if _can_start_lock_on():
start_sniping()
func _process_locking_on(delta: float) -> void:
if not is_instance_valid(_target):
_set_state(State.IDLE)
return
# ターゲットの位置にレーザーを向ける
_update_laser_line()
# 視線が切れたら中断するオプション
if cancel_lock_if_lost_sight and not _has_clear_sight_to_target():
_hide_laser()
_set_state(State.IDLE)
return
# レーザー点滅
if laser_blink:
var phase := sin(_time_in_state * TAU * laser_blink_frequency)
_laser_line.visible = phase >= 0.0
else:
_laser_line.visible = true
# 一定時間経過で発射
if _time_in_state >= lock_on_time:
_fire_bullet()
_hide_laser()
_set_state(State.COOLDOWN)
func _process_cooldown(delta: float) -> void:
if _time_in_state >= cooldown_time:
_set_state(State.IDLE)
# ==========================
# パブリック API
# ==========================
## 外部から明示的に狙撃を開始したいときに呼び出します。
## 例: ボス戦で特定フェーズのときだけ狙撃させる、など。
func start_sniping() -> void:
if _state != State.IDLE:
return
if not _can_start_lock_on():
return
_set_state(State.LOCKING_ON)
_show_laser()
_update_laser_line()
## ターゲットを直接差し替えたい場合に使用します。
func set_target(target: Node2D) -> void:
_target = target
# ==========================
# 内部ユーティリティ
# ==========================
func _set_state(new_state: State) -> void:
_state = new_state
_time_in_state = 0.0
match _state:
State.IDLE:
_hide_laser()
State.LOCKING_ON:
_show_laser()
State.COOLDOWN:
_hide_laser()
func _resolve_target() -> void:
# 1. 明示的な NodePath が設定されていればそれを優先
if target_path != NodePath():
var node := get_node_or_null(target_path)
if node is Node2D:
_target = node
return
# 2. グループから最も近いターゲットを探す
if target_group != StringName():
var candidates := get_tree().get_nodes_in_group(target_group)
var min_dist := INF
var closest: Node2D = null
for c in candidates:
if not (c is Node2D):
continue
var d := (c.global_position - global_position).length()
if d < min_dist:
min_dist = d
closest = c
_target = closest
func _setup_laser_line() -> void:
if laser_line_path != NodePath():
var node := get_node_or_null(laser_line_path)
if node and node is Line2D:
_laser_line = node
_laser_line.visible = false
_laser_line.default_color = laser_color_locked_on
return
# 自動生成
_laser_line = Line2D.new()
_laser_line.name = "SniperLaser"
_laser_line.width = 2.0
_laser_line.default_color = laser_color_locked_on
_laser_line.visible = false
add_child(_laser_line)
# 始点・終点はとりあえずゼロ。ロックオン時に更新します。
_laser_line.points = PackedVector2Array([Vector2.ZERO, Vector2.ZERO])
func _get_muzzle_global_position() -> Vector2:
# このコンポーネントの親を発射位置の基準とします。
var parent_2d := get_parent() as Node2D
if parent_2d:
return parent_2d.global_position + muzzle_offset.rotated(parent_2d.global_rotation)
return global_position + muzzle_offset
func _can_start_lock_on() -> bool:
if not is_instance_valid(_target):
_resolve_target()
if not is_instance_valid(_target):
return false
# 射程チェック
var origin := _get_muzzle_global_position()
var to_target := _target.global_position - origin
if to_target.length() > max_lock_distance:
return false
# 視野角チェック
var parent_2d := get_parent() as Node2D
if parent_2d and field_of_view_deg < 360.0:
var forward := Vector2.RIGHT.rotated(parent_2d.global_rotation)
var angle_deg := rad_to_deg(forward.angle_to(to_target.normalized()))
if abs(angle_deg) > field_of_view_deg * 0.5:
return false
# 視線チェック
if not _has_clear_sight_to_target():
return false
return true
func _has_clear_sight_to_target() -> bool:
if not _cached_space_state:
var world_2d := get_world_2d()
if not world_2d:
return false
_cached_space_state = world_2d.direct_space_state
if not is_instance_valid(_target):
return false
var origin := _get_muzzle_global_position()
var target_pos := _target.global_position
var query := PhysicsRayQueryParameters2D.create(origin, target_pos)
query.collision_mask = obstacle_collision_mask
var result := _cached_space_state.intersect_ray(query)
if result.is_empty():
# 何にも当たらなければ視線は通っている
return true
# 何かに当たったが、それがターゲット自身ならOK とする方法もありますが、
# ここでは単純に「何かに当たったら遮蔽あり」とみなします。
return false
func _update_laser_line() -> void:
if not _laser_line:
return
if not is_instance_valid(_target):
_laser_line.visible = false
return
var origin := _get_muzzle_global_position()
var target_pos := _target.global_position
# Line2D はローカル座標なので、親のローカルに変換します。
var parent_2d := get_parent() as Node2D
var origin_local := (parent_2d and parent_2d.to_local(origin)) if parent_2d else to_local(origin)
var target_local := (parent_2d and parent_2d.to_local(target_pos)) if parent_2d else to_local(target_pos)
_laser_line.points = PackedVector2Array([origin_local, target_local])
func _show_laser() -> void:
if _laser_line:
_laser_line.visible = true
func _hide_laser() -> void:
if _laser_line:
_laser_line.visible = false
func _fire_bullet() -> void:
if not bullet_scene:
push_warning("SniperAim: bullet_scene が設定されていません。弾を発射できません。")
return
if not is_instance_valid(_target):
return
var bullet := bullet_scene.instantiate()
if not (bullet is Node2D):
push_warning("SniperAim: bullet_scene は Node2D 派生シーンである必要があります。")
return
var parent := get_tree().current_scene
if not parent:
parent = get_tree().root
parent.add_child(bullet)
var origin := _get_muzzle_global_position()
var dir := (_target.global_position - origin).normalized()
var bullet_2d := bullet as Node2D
bullet_2d.global_position = origin
bullet_2d.global_rotation = dir.angle()
# 弾側に velocity プロパティ or set_velocity() がある前提で速度を渡します。
# ない場合は弾シーンの実装に合わせてここを調整してください。
if "velocity" in bullet_2d:
bullet_2d.velocity = dir * bullet_speed
elif bullet_2d.has_method("set_velocity"):
bullet_2d.set_velocity(dir * bullet_speed)
else:
# 何もなければ単純に position を毎フレーム動かす簡易スクリプトをアタッチしても良いですね。
push_warning("SniperAim: bullet に速度を設定できませんでした。弾シーン側の実装を確認してください。")
使い方の手順
- 弾丸シーンを用意する
まずはシンプルな高速弾シーンを1つ作っておきましょう。
SniperBullet (Area2D) ├── CollisionShape2D └── Sprite2D
例として、弾丸用のスクリプト SniperBullet.gd はこんな感じでOKです。
extends Area2D
@export var life_time: float = 2.0
var velocity: Vector2 = Vector2.ZERO
func _process(delta: float) -> void:
position += velocity * delta
life_time -= delta
if life_time <= 0.0:
queue_free()
func _on_body_entered(body: Node) -> void:
# ダメージ処理などをここに書く
queue_free()
(body_entered シグナルを SniperBullet に接続しておいてください)
- プレイヤーにグループを付ける
プレイヤーシーンに player グループを付けます(インスペクタ > Node > Groups)。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ...
- 敵シーンに SniperAim コンポーネントをアタッチ
敵のシーン構成例:
SniperEnemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── SniperAim (Node)
SniperEnemy の子として Node を追加し、スクリプトに上記の SniperAim.gd をアタッチします(もしくは class_name SniperAim なので、直接「SniperAim」を選択して追加してもOK)。
インスペクタで以下を設定しましょう。
- bullet_scene : さきほど作った
SniperBullet.tscn - muzzle_offset : 例えば
Vector2(16, 0)(銃口っぽい位置) - max_lock_distance : 800 ~ 1200 くらいに調整
- field_of_view_deg : 60 ~ 120 くらい(狙撃方向を限定したい場合)
- obstacle_collision_mask : 壁や地形の Physics レイヤーを指定
- auto_sniping : 常時狙撃させたいなら ON(デフォルトのままでOK)
- レーザー演出のカスタマイズ
レーザーを自前で作りたい場合は、敵シーンに Line2D を追加して、それを laser_line_path に指定するだけです。
SniperEnemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── LaserLine (Line2D) └── SniperAim (Node)
この場合、Line2D の width や gradient をいじって、レーザーの見た目を自由に変えられます。
指定しなければ、コンポーネント側がシンプルな Line2D を自動生成してくれます。
別の例: 動くタレット床にスナイパー機能だけを合成する
同じコンポーネントを、動く床タイプのタレットにも簡単に流用できます。
MovingTurretPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D ├── Tween └── SniperAim (Node)
MovingTurretPlatform は「移動」に専念させて、狙撃はすべて SniperAim に任せる構成です。
こうしておくと、
- 「動くけど撃たない床」→ SniperAim を外すだけ
- 「撃つけど動かない砲台」→ Tween や移動ロジックを外すだけ
といったバリエーションが、シーン構造を増やさずに合成だけで作れます。
メリットと応用
SniperAim コンポーネントを使うメリットは、ざっくり言うと次の3つです。
- 敵シーンがシンプルになる
「SniperEnemy.gd に全部書く」ではなく、「移動」「見た目」「狙撃」を別々の責務として分離できます。
シーンツリーもSniperEnemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── SniperAimのようにフラットで見通しが良くなります。
- 別の敵タイプにもそのまま再利用できる
近距離メレー敵、飛行敵、ボスの一部パーツ…など、「とりあえず遠距離狙撃させたい」という要件が出たら、このコンポーネントをポンとアタッチするだけでOKです。 - テストとチューニングが楽
射程・視野角・ロックオン時間・クールダウンなど、スナイパーらしさを決めるパラメータが1か所にまとまっています。
デバッグのときはdebug_drawを ON にすれば、狙撃方向のラインが見えるので調整もしやすいですね。
応用としては、
- ロックオン中に「警告サウンド」を鳴らす
- 弾の種類を切り替える(貫通弾、爆発弾など)
- ターゲットをプレイヤー以外(オブジェクト、別の敵)に変える
といった拡張が考えられます。
改造案: ロックオン完了時に敵本体へシグナル通知
「ロックオンが完了した瞬間に、敵本体のアニメーションを変えたい」「SEを鳴らしたい」といったケース向けに、シグナルを1つ追加する改造案です。
signal lock_on_finished
# LOCKING_ON の処理の最後あたりを少し改造
func _process_locking_on(delta: float) -> void:
# ...中略...
if _time_in_state >= lock_on_time:
emit_signal("lock_on_finished") # ★ ここでシグナル発火
_fire_bullet()
_hide_laser()
_set_state(State.COOLDOWN)
これで、親ノード(敵本体)から
func _on_sniper_aim_lock_on_finished() -> void:
$AnimationPlayer.play("shoot")
のようにシーン側で自由に演出を足せます。
「ロジックはコンポーネント」「演出はシーン側」で分けていくと、どんどん合成しやすい設計になっていきますね。
