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. 弾丸シーンを用意する

まずはシンプルな高速弾シーンを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 に接続しておいてください)

  1. プレイヤーにグループを付ける

プレイヤーシーンに player グループを付けます(インスペクタ > Node > Groups)。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ...
  1. 敵シーンに 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)
  1. レーザー演出のカスタマイズ

レーザーを自前で作りたい場合は、敵シーンに Line2D を追加して、それを laser_line_path に指定するだけです。

SniperEnemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── LaserLine (Line2D)
 └── SniperAim (Node)

この場合、Line2D の widthgradient をいじって、レーザーの見た目を自由に変えられます。
指定しなければ、コンポーネント側がシンプルな 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")

のようにシーン側で自由に演出を足せます。
「ロジックはコンポーネント」「演出はシーン側」で分けていくと、どんどん合成しやすい設計になっていきますね。