敵AIを書いていると、だいたい次のような流れになりますよね。

  • プレイヤーを追いかける
  • 見失ったら、その場で立ち止まる or 適当にウロウロする

Godotのチュートリアルや多くのサンプルでは、Enemy.gd みたいな1つの巨大スクリプトに

  • 追跡ロジック
  • パトロールロジック
  • 「見失ったときの挙動」ロジック

を全部詰め込みがちです。さらに、EnemyBaseEnemy から継承して…とやっていくと、

  • ちょっと「見失い方」を変えたいだけで共通親クラスをいじる羽目になる
  • 敵の種類ごとに if 文と状態フラグが増え続ける
  • シーンツリーはスッキリしているのに、スクリプトだけ地獄

という、よくある「継承沼」にハマります。

そこで今回は、「プレイヤーを見失った位置まで移動し、周囲をキョロキョロ見回す」挙動だけを切り出した、コンポーネント指向の 「InvestigatePos」コンポーネント を作ってみましょう。

敵AIの「調査フェーズ」を 1 つの独立したノードとしてアタッチできるようにしておくと、

  • どの敵にも簡単に「見失い調査」行動を追加できる
  • 調査のパラメータ(移動速度、見回し時間など)をインスペクタから個別に調整できる
  • 「この敵は調査しない」ならコンポーネントを外すだけ

という、まさに「継承より合成」の世界になりますね。


【Godot 4】見失ったら現場検証!「InvestigatePos」コンポーネント

このコンポーネントは、ざっくり言うと次のような役割を持ちます。

  1. 外部(敵AI本体)から「見失った位置」を渡される
  2. その位置まで移動する
  3. 到着したら、その場で一定時間キョロキョロ見回す
  4. 終わったらシグナルで「調査完了」を通知する

敵本体のスクリプトは「いつこのコンポーネントを起動するか」だけを考えればよく、
移動や見回しの細かい挙動は InvestigatePos に丸投げできます。


フルコード:InvestigatePos.gd


extends Node
class_name InvestigatePos
##
## InvestigatePos (不審点調査) コンポーネント
##
## ・プレイヤーを見失った「最後に見えた位置」まで移動し、
##   その場でキョロキョロ見回す挙動を提供するコンポーネントです。
## ・敵本体(CharacterBody2D など)にアタッチして使います。
## ・移動処理は「親ノード」を動かす前提で実装しています。
##

## 調査フェーズが完了したときに発火するシグナル
signal investigation_finished

## ====== 設定パラメータ(インスペクタから調整) ======

@export_category("Movement")

## 調査地点へ向かうときの移動速度(ピクセル/秒)
@export var move_speed: float = 120.0

## 調査地点に「到着した」とみなす距離(ピクセル)
## 小さくしすぎると到着判定がシビアになり、プルプル震える原因になります。
@export var arrive_threshold: float = 4.0

## 親ノードが X 軸方向にフリップして向きを表現している場合は true にします。
## 例: Sprite2D.flip_h を向きとして扱っている場合など。
@export var use_flip_for_facing: bool = true

@export_category("Investigation")

## 調査地点に到着してから「見回し」を行う合計時間(秒)
@export var investigation_time: float = 2.0

## 見回し中に左右を向き直す間隔(秒)
@export var look_around_interval: float = 0.4

## 見回し中に回転させる角度(度数法)。0 の場合は回転させず、左右の向きだけを切り替えます。
@export_range(-45.0, 45.0, 1.0)
@export var look_angle_deg: float = 15.0

## 見回し中もわずかにその場をウロウロさせる距離(ピクセル)
## 0 にすると完全にその場で静止して見回します。
@export var jitter_distance: float = 2.0

## 調査中に親ノードのアニメーションを切り替えたい場合に使うアニメーション名
## 空文字列のままなら何もしません。
@export_category("Animation")
@export var idle_animation_name: String = "idle"
@export var move_animation_name: String = "walk"
@export var investigate_animation_name: String = "look_around"

## アニメーション再生に使うノードへのパス(オプション)
## - AnimationPlayer
## - または AnimatedSprite2D(Godot 4 では AnimatedSprite2D のまま)
@export var animation_player_path: NodePath

## ====== 内部状態 ======

var _target_position: Vector2
var _is_investigating: bool = false
var _phase: String = "idle" # "idle" / "moving" / "looking"

var _investigation_timer: float = 0.0
var _look_timer: float = 0.0
var _original_rotation: float = 0.0
var _original_position: Vector2
var _look_direction: int = 1 # 1 or -1

var _animation_player: Node = null


func _ready() -> void:
    # AnimationPlayer / AnimatedSprite2D を自動取得
    if animation_player_path != NodePath():
        _animation_player = get_node_or_null(animation_player_path)
    else:
        # 自動で探す(任意)。なければ null のまま。
        _animation_player = find_child("AnimationPlayer", false, false)
        if _animation_player == null:
            _animation_player = find_child("AnimatedSprite2D", false, false)


func _process(delta: float) -> void:
    if not _is_investigating:
        return

    match _phase:
        "moving":
            _process_moving(delta)
        "looking":
            _process_looking(delta)


## 調査を開始する
##
## @param last_seen_position: プレイヤーを最後に目撃したワールド座標
##                            (グローバル座標)を渡してください。
func start_investigation(last_seen_position: Vector2) -> void:
    _target_position = last_seen_position
    _is_investigating = true
    _phase = "moving"

    _play_animation(move_animation_name)

    # 調査開始時の初期化
    _investigation_timer = 0.0
    _look_timer = 0.0
    _look_direction = 1

    # 調査開始時に回転をリセット(任意)
    if owner and owner is Node2D:
        _original_rotation = owner.rotation
        _original_position = owner.global_position


## 調査を強制終了する(途中キャンセル)
func cancel_investigation() -> void:
    if not _is_investigating:
        return

    _is_investigating = false
    _phase = "idle"

    # 向き・回転を元に戻す
    if owner and owner is Node2D:
        owner.rotation = _original_rotation

    _play_animation(idle_animation_name)


## 現在調査中かどうか
func is_investigating() -> bool:
    return _is_investigating


## ====== 内部処理:移動フェーズ ======

func _process_moving(delta: float) -> void:
    if owner == null or not (owner is Node2D):
        push_warning("InvestigatePos: owner is not Node2D. Movement is skipped.")
        return

    var body := owner as Node2D

    # 目標へのベクトル
    var to_target: Vector2 = _target_position - body.global_position
    var distance: float = to_target.length()

    # 到着判定
    if distance <= arrive_threshold:
        _enter_looking_phase()
        return

    # 正規化して速度を掛ける
    var dir: Vector2 = to_target.normalized()
    var movement: Vector2 = dir * move_speed * delta

    # 動かしすぎないようにクランプ
    if movement.length() > distance:
        movement = dir * distance

    body.global_position += movement

    # 向きの更新(左右)
    if use_flip_for_facing:
        _update_facing_by_flip(dir.x)
    else:
        _update_facing_by_rotation(dir)


## ====== 内部処理:見回しフェーズ ======

func _enter_looking_phase() -> void:
    _phase = "looking"
    _investigation_timer = 0.0
    _look_timer = 0.0

    if owner and owner is Node2D:
        _original_rotation = owner.rotation
        _original_position = owner.global_position

    _play_animation(investigate_animation_name)


func _process_looking(delta: float) -> void:
    if owner == null or not (owner is Node2D):
        return

    var body := owner as Node2D

    _investigation_timer += delta
    _look_timer += delta

    # 一定間隔ごとに左右の向きを切り替えたり、回転させたりする
    if _look_timer >= look_around_interval:
        _look_timer = 0.0
        _look_direction *= -1  # 左右反転

        if use_flip_for_facing:
            _flip_body()
        # 回転も使う場合
        if look_angle_deg != 0.0:
            var rad := deg_to_rad(look_angle_deg * _look_direction)
            body.rotation = _original_rotation + rad

    # その場で小さくウロウロする(jitter)
    if jitter_distance > 0.0:
        var offset_dir := Vector2.RIGHT.rotated(Time.get_ticks_msec() / 200.0) * jitter_distance
        body.global_position = _original_position + offset_dir
    else:
        body.global_position = _original_position

    # 見回し時間が終わったら完了
    if _investigation_timer >= investigation_time:
        _finish_investigation()


func _finish_investigation() -> void:
    _is_investigating = false
    _phase = "idle"

    # 回転と位置を戻す
    if owner and owner is Node2D:
        owner.rotation = _original_rotation
        owner.global_position = _original_position

    _play_animation(idle_animation_name)

    emit_signal("investigation_finished")


## ====== 向き・アニメーション関連のヘルパー ======

func _update_facing_by_flip(x_dir: float) -> void:
    if x_dir == 0.0 or owner == null:
        return

    # Sprite2D or AnimatedSprite2D を探して flip を反転
    var sprite := owner.get_node_or_null("Sprite2D")
    if sprite and sprite is Sprite2D:
        sprite.flip_h = x_dir < 0.0
        return

    var anim_sprite := owner.get_node_or_null("AnimatedSprite2D")
    if anim_sprite and anim_sprite is AnimatedSprite2D:
        anim_sprite.flip_h = x_dir < 0.0
        return


func _update_facing_by_rotation(dir: Vector2) -> void:
    if owner == null or not (owner is Node2D):
        return
    if dir.length() == 0.0:
        return
    var body := owner as Node2D
    body.rotation = dir.angle()


func _flip_body() -> void:
    if owner == null:
        return

    var sprite := owner.get_node_or_null("Sprite2D")
    if sprite and sprite is Sprite2D:
        sprite.flip_h = not sprite.flip_h
        return

    var anim_sprite := owner.get_node_or_null("AnimatedSprite2D")
    if anim_sprite and anim_sprite is AnimatedSprite2D:
        anim_sprite.flip_h = not anim_sprite.flip_h
        return


func _play_animation(name: String) -> void:
    if name == "" or _animation_player == null:
        return

    # AnimationPlayer の場合
    if _animation_player is AnimationPlayer:
        var ap := _animation_player as AnimationPlayer
        if ap.has_animation(name):
            ap.play(name)
        return

    # AnimatedSprite2D の場合
    if _animation_player is AnimatedSprite2D:
        var as2d := _animation_player as AnimatedSprite2D
        if name in as2d.sprite_frames.get_animation_names():
            as2d.play(name)
        return

使い方の手順

ここでは 2D の敵キャラを例に、具体的な使い方を見ていきましょう。

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

まずは敵シーンの構成を、こんな感じにしておきます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── InvestigatePos (Node)  <-- ここに本コンポーネントをアタッチ
  1. InvestigatePos.gd をプロジェクト内に保存(例: res://components/InvestigatePos.gd
  2. Enemy シーンを開き、Enemy(親の CharacterBody2D)に子ノードとして Node を追加
  3. その Node に InvestigatePos.gd をアタッチ
  4. ノード名を InvestigatePos に変更(任意ですが分かりやすく)

インスペクタで、以下のように設定しておきましょう。

  • move_speed:敵の移動速度に合わせる(例: 100〜150)
  • investigation_time:見回す時間(例: 2.0〜4.0 秒)
  • look_around_interval:首振りの間隔(例: 0.3〜0.6 秒)
  • animation_player_path../AnimationPlayer など
  • idle_animation_name / move_animation_name / investigate_animation_name:自分のアニメ名に合わせる

手順②:敵本体のスクリプトから呼び出す

次に、Enemy 本体のスクリプト(例: Enemy.gd)から、プレイヤーを見失ったタイミングで start_investigation() を呼びます。


extends CharacterBody2D

@onready var investigate_pos: InvestigatePos = $InvestigatePos

var player: Node2D
var last_seen_player_pos: Vector2
var can_see_player: bool = false

enum State { PATROL, CHASE, INVESTIGATE }
var state: State = State.PATROL


func _ready() -> void:
    # プレイヤーを取得(例として "Player" グループから検索)
    var players := get_tree().get_nodes_in_group("Player")
    if players.size() > 0:
        player = players[0] as Node2D

    # 調査完了シグナルを受け取る
    investigate_pos.investigation_finished.connect(_on_investigation_finished)


func _physics_process(delta: float) -> void:
    match state:
        State.PATROL:
            _process_patrol(delta)
        State.CHASE:
            _process_chase(delta)
        State.INVESTIGATE:
            # InvestigatePos コンポーネントが勝手に動くので、
            # ここでは何もしないか、待ち状態アニメを流すだけでOK
            pass


func _process_patrol(delta: float) -> void:
    # ここは適当なパトロール処理を想定
    # プレイヤーを見つけたら CHASE へ
    if _can_see_player():
        state = State.CHASE


func _process_chase(delta: float) -> void:
    if player == null:
        return

    # プレイヤーの方向に移動する(簡略版)
    var dir := (player.global_position - global_position).normalized()
    velocity = dir * 160.0
    move_and_slide()

    # 見えている間は位置を記録しておく
    if _can_see_player():
        last_seen_player_pos = player.global_position
    else:
        # 見失ったら INVESTIGATE フェーズへ移行
        state = State.INVESTIGATE
        investigate_pos.start_investigation(last_seen_player_pos)


func _can_see_player() -> bool:
    # 本当はレイキャストなどで視界チェックするのが理想ですが、
    # ここでは距離だけで簡易判定します。
    if player == null:
        return false
    var max_distance := 300.0
    return global_position.distance_to(player.global_position) <= max_distance


func _on_investigation_finished() -> void:
    # 調査が終わったらパトロールに戻る
    state = State.PATROL

このように、Enemy 本体は「いつ調査を始めるか」と「調査が終わったらどうするか」だけを管理し、
移動や見回しの細かい処理は InvestigatePos に丸投げできます。

手順③:別の敵にもそのまま使い回す

コンポーネント化の真価は、ここからです。

たとえば「重い鎧を着た騎士」と「素早い忍者」の 2 種類の敵がいたとしても、

Knight (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── InvestigatePos (Node)

Ninja (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── InvestigatePos (Node)

という構成にしておけば、

  • Knight の InvestigatePos.move_speed = 80
  • Ninja の InvestigatePos.move_speed = 200
  • 見回し時間や首振り角度もキャラごとに調整

といったチューニングを、インスペクタからポチポチ変えるだけで実現できます。
巨大な EnemyBase.gd をいじって if 文を増やす必要はありません。

手順④:動く床や警備ドローンなどにも応用

このコンポーネントは「プレイヤーを見失った敵」だけでなく、

  • プレイヤーを検知すると現場まで移動してしばらく停止する 警備ドローン
  • プレイヤーが乗っていた位置まで戻って一瞬止まる 動く床

などにも使い回せます。
要は「何かを見失った/検知した位置まで移動して、その場で一定時間キョロキョロする」挙動全般に使えるわけですね。


メリットと応用

InvestigatePos コンポーネントを導入することで、次のようなメリットがあります。

  • 敵AIの責務分離:Enemy 本体は「状態遷移」と「検知ロジック」に集中できる
  • シーン構造は浅いまま:複雑なサブシーンやネストされた状態マシンを作らなくてよい
  • パラメータ駆動:見回し時間・速度・角度などをインスペクタから調整可能
  • 再利用性:別の敵、別のゲームでもそのままコピペで使える
  • テストがしやすい:InvestigatePos だけをテストシーンに置いて挙動確認ができる

「見失ったときの挙動」はゲームの手触りにかなり効いてくる部分なので、
巨大な Enemy スクリプトに埋め込んでしまうよりも、こうして 1 コンポーネントとして独立させておくと、
後からの調整・差し替えがとても楽になります。

改造案:調査中に「音」を鳴らす

例えば、見回しを始めたときに「?」のボイスや効果音を鳴らしたい場合、
次のような簡単な拡張ができます。


@export_category("Sound")
@export var investigate_sound_path: NodePath

var _investigate_sound: AudioStreamPlayer2D


func _ready() -> void:
    # 既存の _ready() の末尾あたりに追加
    if investigate_sound_path != NodePath():
        _investigate_sound = get_node_or_null(investigate_sound_path)


func _enter_looking_phase() -> void:
    _phase = "looking"
    _investigation_timer = 0.0
    _look_timer = 0.0

    if owner and owner is Node2D:
        _original_rotation = owner.rotation
        _original_position = owner.global_position

    _play_animation(investigate_animation_name)

    # ここで効果音を再生
    if _investigate_sound:
        _investigate_sound.play()

Enemy の子として AudioStreamPlayer2D を置き、
investigate_sound_path にそのノードを指定しておけば、
「見失ったら現場まで走っていき、立ち止まって『ん?』と声を出しながらキョロキョロする敵」が、
ほぼノーコードで量産できます。

こういう小さな行動単位をコンポーネントとして切り出しておくと、
ゲーム全体の AI 設計がかなりスッキリしてくるので、ぜひ積極的に「合成」で攻めていきたいですね。