Godot 4 で敵 AI を作るとき、つい「敵ベースクラス」を作って、そこから SlimeEnemyBatEnemy を継承して…というスタイルに走りがちですよね。でも、少し複雑なギミックを入れようとすると、

  • 「待ち伏せする敵だけ透明化ロジックが欲しい」
  • 「一部の敵だけ、近づいたら擬態を解いて攻撃したい」

といった「一部の敵だけに必要な機能」が増えていき、ベースクラスがどんどん肥大化していきます。
さらに、ノード階層も

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 でコンポーネント指向を進めたい方は、ぜひこうした小さな行動単位のコンポーネントを積み重ねていきましょう。