Godot 4 で敵 AI を作るとき、つい「敵ベースクラス」を作って、そこから SlimeEnemy や BatEnemy を継承して…というスタイルに走りがちですよね。でも、少し複雑なギミックを入れようとすると、
- 「待ち伏せする敵だけ透明化ロジックが欲しい」
- 「一部の敵だけ、近づいたら擬態を解いて攻撃したい」
といった「一部の敵だけに必要な機能」が増えていき、ベースクラスがどんどん肥大化していきます。
さらに、ノード階層も
EnemyBase (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── AnimationPlayer ├── ... └── AmbushLogic
のように、敵の種類ごとに専用スクリプトを作りまくると、「この敵はどこにロジックがあるんだっけ?」と迷子になりがちです。
そこで今回は、「待ち伏せ(Ambush)」という行動だけをコンポーネント化した 「Ambusher」コンポーネント を用意してみましょう。
プレイヤーが近づくまでは透明や岩に擬態しておき、一定距離に入ったら正体を現して攻撃状態に移行する、というギミックを、どんな敵にも「後付け」できるようにします。
【Godot 4】近づいたら正体バレ!「Ambusher」コンポーネント
このコンポーネントは、
- 「待ち伏せしたい側」(敵本体など)にアタッチするだけ
- プレイヤーを検知すると「擬態解除 → 攻撃モードへ移行」のトリガーを送る
- 見た目(岩 or 透明)、検知距離、遅延時間などを
@exportで柔軟に調整
という思想で作っています。
継承せずに「合成(Composition)」で行動を追加できるので、他の敵にも簡単に流用できますね。
フルコード:Ambusher.gd
extends Node
class_name Ambusher
## 待ち伏せ(擬態 → プレイヤー検知 → 正体を現す)を担当するコンポーネント。
##
## 想定:
## - 親ノードは敵本体(CharacterBody2D / Node2D など)
## - プレイヤーを検知したら、見た目を変えつつ signal で通知
## - 攻撃ロジック自体は親側のスクリプトで書く(合成スタイル)
signal ambush_started(target: Node) ## プレイヤーを検知して、待ち伏せ解除が始まったとき
signal ambush_revealed(target: Node) ## 擬態が完全に解除され、攻撃開始できる状態になったとき
@export_group("Detection", "detection_")
## プレイヤーを検知する半径(ピクセル単位)
@export var detection_radius: float = 160.0
## プレイヤーを検知する対象グループ名
@export var detection_target_group: StringName = &"player"
## プレイヤーを検知する更新間隔(秒)
@export var detection_poll_interval: float = 0.1
## 一度バレたら、再び擬態しない場合は true
@export var one_shot: bool = true
@export_group("Appearance", "appearance_")
## 最初は完全に透明になるかどうか(Sprite2D / CanvasItem の modulate.a を変更)
@export var appearance_start_invisible: bool = false
## 岩に擬態する用の Sprite テクスチャ(null の場合は何もしない)
@export var appearance_rock_texture: Texture2D
## 正体を現したときに戻す本来のテクスチャ(null の場合は現在のテクスチャを保持)
@export var appearance_true_texture: Texture2D
## 擬態解除時にフェードインする時間(秒)。0 なら即時切り替え
@export var appearance_reveal_duration: float = 0.4
@export_group("Reveal & Attack Flow", "reveal_")
## プレイヤーを検知してから、擬態解除を開始するまでの遅延(秒)
@export var reveal_start_delay: float = 0.0
## 擬態解除が終わってから、攻撃ロジックを開始するまでの遅延(秒)
@export var reveal_to_attack_delay: float = 0.0
## 擬態中は親ノードの移動を止めるかどうか(CharacterBody2D / RigidBody2D を想定)
@export var freeze_parent_while_hidden: bool = true
@export_group("Debug", "debug_")
## 検知範囲をエディタ/デバッグ中に描画するかどうか
@export var debug_draw_detection_radius: bool = true
## プレイヤーがいなくても自動的にテスト用のダミーターゲットを探す(デバッグ用)
@export var debug_allow_any_target: bool = false
## 内部状態
var _is_hidden: bool = true
var _has_triggered: bool = false
var _current_target: Node = null
## キャッシュ
var _sprite: Sprite2D = null
var _canvas_item: CanvasItem = null
var _original_texture: Texture2D = null
var _original_modulate: Color
var _poll_timer: Timer
func _ready() -> void:
_setup_parent_visual()
_setup_poll_timer()
_enter_hidden_state()
func _setup_parent_visual() -> void:
## 親ノードの Sprite2D もしくは CanvasItem を探す
var parent := get_parent()
if not parent:
push_warning("Ambusher: 親ノードが存在しません。何もできません。")
return
# Sprite2D を優先的に探す
_sprite = parent.get_node_or_null("Sprite2D")
if _sprite:
_canvas_item = _sprite
_original_modulate = _sprite.modulate
_original_texture = _sprite.texture
elif parent is CanvasItem:
_canvas_item = parent
_original_modulate = _canvas_item.modulate
# CanvasItem には texture がないので、texture 切り替えは諦める
else:
# ビジュアルを持たない敵でも、signal だけ使うことは可能
push_warning("Ambusher: 親に Sprite2D / CanvasItem が見つかりません。見た目の擬態は行われません。")
func _setup_poll_timer() -> void:
_poll_timer = Timer.new()
_poll_timer.wait_time = detection_poll_interval
_poll_timer.autostart = true
_poll_timer.one_shot = false
_poll_timer.timeout.connect(_on_poll_timeout)
add_child(_poll_timer)
func _enter_hidden_state() -> void:
_is_hidden = true
_current_target = null
if freeze_parent_while_hidden:
_set_parent_frozen(true)
# 見た目を擬態状態にする
if _sprite:
if appearance_rock_texture:
# 岩テクスチャに差し替え
_sprite.texture = appearance_rock_texture
if appearance_start_invisible:
var c := _sprite.modulate
c.a = 0.0
_sprite.modulate = c
elif _canvas_item and appearance_start_invisible:
var c := _canvas_item.modulate
c.a = 0.0
_canvas_item.modulate = c
func _set_parent_frozen(freeze: bool) -> void:
var parent := get_parent()
if not parent:
return
# CharacterBody2D の場合、移動ロジック側でこのフラグを参照するようにしておくと良い
if parent.has_method("set_physics_process"):
parent.set_physics_process(not freeze)
# RigidBody2D などの場合は mode を切り替えるのも一案
if "freeze" in parent:
parent.freeze = freeze
func _on_poll_timeout() -> void:
if _has_triggered and one_shot:
return
var target := _find_target()
if target:
_current_target = target
_has_triggered = true
_start_ambush_flow(target)
func _find_target() -> Node:
var world := get_tree()
if not world:
return null
var candidates: Array = []
if detection_target_group != StringName():
candidates = world.get_nodes_in_group(detection_target_group)
elif debug_allow_any_target:
candidates = world.get_nodes_in_group(&"player") + world.get_nodes_in_group(&"enemy") + world.get_nodes_in_group(&"unit")
if candidates.is_empty():
return null
var my_global_pos: Vector2 = _get_parent_global_position()
var best_target: Node = null
var best_dist_sq := INF
for node in candidates:
if not node is Node2D:
continue
var pos: Vector2 = node.global_position
var dist_sq := my_global_pos.distance_squared_to(pos)
if dist_sq <= detection_radius * detection_radius and dist_sq < best_dist_sq:
best_dist_sq = dist_sq
best_target = node
return best_target
func _get_parent_global_position() -> Vector2:
var parent := get_parent()
if parent and parent is Node2D:
return parent.global_position
return Vector2.ZERO
func _start_ambush_flow(target: Node) -> void:
if not _is_hidden:
return
_is_hidden = false
ambush_started.emit(target)
# 擬態中フリーズしていた場合は、すぐには解除せず、フェーズに合わせて制御
if freeze_parent_while_hidden:
_set_parent_frozen(true)
# コルーチンでフェーズを順に進める
_run_ambush_sequence.call_deferred(target)
func _run_ambush_sequence(target: Node) -> void:
# 1. 検知から擬態解除開始までのディレイ
if reveal_start_delay > 0.0:
await get_tree().create_timer(reveal_start_delay).timeout
# 2. 見た目を正体に戻す(フェードイン付き)
await _reveal_visual()
# 3. 擬態解除から攻撃開始までのディレイ
if reveal_to_attack_delay > 0.0:
await get_tree().create_timer(reveal_to_attack_delay).timeout
# 4. 攻撃開始可能を通知
ambush_revealed.emit(target)
# 5. 親のフリーズを解除
if freeze_parent_while_hidden:
_set_parent_frozen(false)
func _reveal_visual() -> void:
if not _sprite and not _canvas_item:
return
# テクスチャを本来のものに戻す
if _sprite:
if appearance_true_texture:
_sprite.texture = appearance_true_texture
elif _original_texture:
_sprite.texture = _original_texture
# フェードインが不要なら即座に戻す
if appearance_reveal_duration <= 0.0:
if _canvas_item:
_canvas_item.modulate = _original_modulate
return
# Tween を使ってアルファ値を 0 → 元の値 に補間
var tween := create_tween()
tween.set_trans(Tween.TRANS_QUAD)
tween.set_ease(Tween.EASE_OUT)
var from_color: Color = _canvas_item.modulate if _canvas_item else _original_modulate
var to_color: Color = _original_modulate
# 開始時点の alpha が 0 でない場合もあるので、from_color からそのまま補間
tween.tween_property(_canvas_item, "modulate", to_color, appearance_reveal_duration).from(from_color)
await tween.finished
# --- 公開 API -------------------------------------------------------------
## 手動で待ち伏せ状態に戻す(one_shot = false のときに利用)
func reset_ambush() -> void:
_has_triggered = false
_enter_hidden_state()
## 手動で強制的に擬態解除を行う(プレイヤー検知を待たない)
func force_reveal(target: Node = null) -> void:
if not target:
target = _find_target()
if not target:
return
if _has_triggered and one_shot:
return
_has_triggered = true
_start_ambush_flow(target)
## 現在擬態中かどうか
func is_hidden() -> bool:
return _is_hidden
## 現在ロックオンしているターゲットを返す(null の可能性あり)
func get_current_target() -> Node:
return _current_target
使い方の手順
ここでは 2D を例に、「岩に擬態している敵」がプレイヤーに近づかれると正体を現し、攻撃モードに移行するパターンを作ってみます。
手順①:敵シーンを用意する
まずは、普通の敵シーンを作ります(まだ待ち伏せはしません)。
RockAmbushEnemy (CharacterBody2D) ├── Sprite2D # 岩の見た目 or 本来の敵の見た目 ├── CollisionShape2D └── Ambusher (Node) # ← このノードに Ambusher.gd をアタッチ
RockAmbushEnemyに移動や攻撃のロジックを書くためのスクリプト(例:rock_ambush_enemy.gd)をアタッチします。Ambusherノードを追加し、上記のAmbusher.gdをアタッチします。
手順②:Ambusher のパラメータを設定する
インスペクタで Ambusher ノードを選び、以下のように設定してみましょう。
- Detection
detection_radius: 180(プレイヤーにかなり近づかれたらバレる)detection_target_group:player(プレイヤーノードにplayerグループを付けておく)
- Appearance
appearance_start_invisible: false(最初は岩として見えている)appearance_rock_texture: 岩の画像appearance_true_texture: 本来の敵の画像appearance_reveal_duration: 0.4(0.4 秒かけてフェードイン)
- Reveal & Attack Flow
reveal_start_delay: 0.3(近づかれてから 0.3 秒待ってから動き出す)reveal_to_attack_delay: 0.2(見た目が変わってから 0.2 秒後に攻撃開始)freeze_parent_while_hidden: true(擬態中は動かない)
手順③:敵本体側で signal を受けて攻撃モードへ
次に、敵本体(RockAmbushEnemy)のスクリプトで、ambush_revealed シグナルを受けて攻撃状態に移行するようにします。
# rock_ambush_enemy.gd
extends CharacterBody2D
enum State {
HIDDEN, # 擬態中(Ambusher が管理)
ALERT, # 正体を現した直後(攻撃準備)
CHASING, # プレイヤーを追いかける
}
var state: State = State.HIDDEN
var move_speed: float = 120.0
var target: Node2D = null
func _ready() -> void:
var ambusher: Ambusher = $Ambusher
# 擬態解除開始時(「気づいた」瞬間)に反応したい場合はこちら
ambusher.ambush_started.connect(_on_ambush_started)
# 擬態解除完了後(攻撃してよいタイミング)はこちら
ambusher.ambush_revealed.connect(_on_ambush_revealed)
func _physics_process(delta: float) -> void:
match state:
State.HIDDEN:
# Ambusher が勝手に待ち伏せしてくれるので、ここでは何もしない
velocity = Vector2.ZERO
State.ALERT:
# ここで軽く震えたり、吠えるアニメーションを再生しても良いですね
velocity = Vector2.ZERO
State.CHASING:
if target and is_instance_valid(target):
var dir := (target.global_position - global_position).normalized()
velocity = dir * move_speed
else:
velocity = Vector2.ZERO
move_and_slide()
func _on_ambush_started(target_node: Node) -> void:
# プレイヤーに気づいた瞬間。ここで効果音を鳴らしたりできる
if target_node is Node2D:
target = target_node
state = State.ALERT
print("RockAmbushEnemy: プレイヤーを発見!")
func _on_ambush_revealed(target_node: Node) -> void:
# 擬態解除完了。ここから本格的に追いかけ開始
if target_node is Node2D:
target = target_node
state = State.CHASING
print("RockAmbushEnemy: 追跡開始!")
こうしておくと、待ち伏せロジック(検知・擬態解除)は Ambusher 側、攻撃ロジック(移動・攻撃)は敵本体側ときれいに分離できます。
手順④:他の敵やギミックにもそのまま流用する
このコンポーネントは「待ち伏せ」という行動だけを提供しているので、敵以外にも簡単に使い回せます。
- 動く床が突然現れるギミック
AmbushPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── Ambusher (Node)ambush_revealedを受けて CollisionShape2D を有効化すれば、「近づいたら足場が現れる」仕掛けになります。 - 宝箱に擬態したミミック
MimicChest (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── Ambusher (Node)プレイヤーが近づくと、宝箱の見た目からモンスターに変身して襲いかかる…という演出も、テクスチャの差し替えとシグナルで簡単に実現できます。
メリットと応用
この「Ambusher」コンポーネントを導入することで、次のようなメリットがあります。
- 待ち伏せロジックを 1 箇所に集約
「プレイヤーを検知してからのフロー(遅延・フェード・通知)」をすべてコンポーネント側に閉じ込められるので、敵ごとにコピペする必要がなくなります。 - 敵のベースクラスを肥大化させない
「待ち伏せする敵だけが欲しい機能」なので、ベースクラスに書くと無駄が増えます。コンポーネントとして後付けすれば、必要な敵にだけアタッチすれば OK です。 - レベルデザインが直感的になる
シーンツリーを見れば、「この敵は Ambusher を持っているから待ち伏せするな」と一目で分かります。ノード構造も浅く保てて、管理が楽になります。 - ビジュアルとロジックの分離
擬態の見た目(岩 or 透明 or 別スプライト)は@exportで差し替えるだけ。コードをいじらずに、レベルデザイナーがいろいろなパターンを試せます。
さらに、応用として「一度バレた後、しばらくするとまた岩に戻る」ような敵も作れます。
RockAmbushEnemy 側で reset_ambush() を呼ぶだけでもよいですが、もう少し自動化したければ、こんな改造もアリです。
# Ambusher に追加する例:一定時間後に自動で再び擬態する
@export_group("Auto Reset", "auto_reset_")
@export var auto_reset_enabled: bool = false
@export var auto_reset_delay: float = 5.0
func _on_ambush_revealed(target: Node) -> void:
# 既存の処理の最後に、オートリセットを差し込む
if auto_reset_enabled and not one_shot:
_schedule_auto_reset()
func _schedule_auto_reset() -> void:
await get_tree().create_timer(auto_reset_delay).timeout
if not one_shot:
reset_ambush()
このように、「待ち伏せ」という 1 つの行動をコンポーネントとして切り出しておけば、継承地獄に陥らずに、敵やギミックのバリエーションをどんどん増やしていけるようになります。
Godot 4 でコンポーネント指向を進めたい方は、ぜひこうした小さな行動単位のコンポーネントを積み重ねていきましょう。
