敵AIを書いていると、だいたい次のような流れになりますよね。
- プレイヤーを追いかける
- 見失ったら、その場で立ち止まる or 適当にウロウロする
Godotのチュートリアルや多くのサンプルでは、Enemy.gd みたいな1つの巨大スクリプトに
- 追跡ロジック
- パトロールロジック
- 「見失ったときの挙動」ロジック
を全部詰め込みがちです。さらに、Enemy を BaseEnemy から継承して…とやっていくと、
- ちょっと「見失い方」を変えたいだけで共通親クラスをいじる羽目になる
- 敵の種類ごとに if 文と状態フラグが増え続ける
- シーンツリーはスッキリしているのに、スクリプトだけ地獄
という、よくある「継承沼」にハマります。
そこで今回は、「プレイヤーを見失った位置まで移動し、周囲をキョロキョロ見回す」挙動だけを切り出した、コンポーネント指向の 「InvestigatePos」コンポーネント を作ってみましょう。
敵AIの「調査フェーズ」を 1 つの独立したノードとしてアタッチできるようにしておくと、
- どの敵にも簡単に「見失い調査」行動を追加できる
- 調査のパラメータ(移動速度、見回し時間など)をインスペクタから個別に調整できる
- 「この敵は調査しない」ならコンポーネントを外すだけ
という、まさに「継承より合成」の世界になりますね。
【Godot 4】見失ったら現場検証!「InvestigatePos」コンポーネント
このコンポーネントは、ざっくり言うと次のような役割を持ちます。
- 外部(敵AI本体)から「見失った位置」を渡される
- その位置まで移動する
- 到着したら、その場で一定時間キョロキョロ見回す
- 終わったらシグナルで「調査完了」を通知する
敵本体のスクリプトは「いつこのコンポーネントを起動するか」だけを考えればよく、
移動や見回しの細かい挙動は 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) <-- ここに本コンポーネントをアタッチ
InvestigatePos.gdをプロジェクト内に保存(例:res://components/InvestigatePos.gd)- Enemy シーンを開き、
Enemy(親の CharacterBody2D)に子ノードとしてNodeを追加 - その Node に
InvestigatePos.gdをアタッチ - ノード名を
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 設計がかなりスッキリしてくるので、ぜひ積極的に「合成」で攻めていきたいですね。
