Godot 4で敵AIを作ろうとすると、つい「敵ベースクラス」を1つ作って、そこに視覚・聴覚・ステートマシン・移動ロジックを全部詰め込みたくなりますよね。
でもそうすると、

  • 敵ごとに挙動を変えたいたびに継承ツリーが伸びる
  • 「この敵は音だけ聞ければいい」「この敵は視覚だけでいい」といった調整がしづらい
  • シーンツリーの階層が深くなり、どこに何の処理があるか分かりにくい

といった問題が出てきます。

そこで今回は、「音を聞く」という機能だけを切り出した HearingSensor コンポーネント を作って、
敵AIにポン付けできる「聴覚モジュール」として扱えるようにしてみましょう。

プレイヤー側は「音シグナル」を発生させ、敵側はこのコンポーネントでそれを受信し、
「音のした場所へ調査に向かう」ような AI を、継承に頼らず 合成(Composition) で組み立てていきます。


【Godot 4】音に反応する敵AIをコンポーネントで!「HearingSensor」コンポーネント

今回のコンポーネントの役割はシンプルです:

  • 「音イベント」を受け取る(グローバル or ローカルなシグナル)
  • 受信した音の位置・強さを記録する
  • 「音を聞いたよ」というシグナルを敵本体(親ノード)に発行する

敵本体は「どこへ移動するか」「どう調査するか」だけに集中できるので、
聴覚ロジックと移動ロジックをきれいに分離できます。


フルコード: HearingSensor.gd


extends Node
class_name HearingSensor
## 聴覚センサーコンポーネント
## - プレイヤーなどが発する「音イベント」を受信して、
##   最後に聞こえた音の位置を記録し、親ノードに通知します。
##
## 想定する使い方:
## - 敵シーンの子ノードとして HearingSensor を追加
## - グローバルな「AudioEventBus」シングルトンから音シグナルを受信
## - 親ノードが `heard_sound` シグナルを受けて、音の方向へ移動する

## ====== エディタで調整するパラメータ ======

@export_range(0.0, 2000.0, 10.0, "or_greater")
var hearing_radius: float = 600.0
## この半径以内の音だけを「聞こえた」とみなす
## 0 にすると無制限(距離チェックをスキップ)

@export_range(0.0, 1.0, 0.01)
var min_volume_threshold: float = 0.1
## この音量未満の音は無視する(足音と爆発音を区別したいときなど)

@export var use_global_bus: bool = true
## true: グローバルな「AudioEventBus」シングルトンから音を受信
## false: 近くのノードから直接シグナル接続する前提

@export var auto_connect_on_ready: bool = true
## true のとき _ready() で自動的に AudioEventBus へ接続を試みる

@export var debug_draw: bool = false
## true にすると hearing_radius を簡易的に可視化(2D専用)

@export var debug_color: Color = Color(0.2, 0.8, 1.0, 0.2)

## ====== 出力シグナル ======

signal heard_sound(global_position: Vector2, volume: float, source: Node)
## - 音を検知したときに発火
## - 親ノード(敵AIなど)がこれを受けて「調査ステート」に遷移する想定

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

var last_sound_position: Vector2 = Vector2.ZERO
var last_sound_volume: float = 0.0
var last_sound_source: Node = null
var has_pending_sound: bool = false

func _ready() -> void:
    if use_global_bus and auto_connect_on_ready:
        _try_connect_to_global_bus()


## グローバルな AudioEventBus (autoload) への接続を試みる
func _try_connect_to_global_bus() -> void:
    if not Engine.has_singleton("AudioEventBus"):
        push_warning("AudioEventBus singleton is not registered. "
            + "Add it as an autoload, or set `use_global_bus = false`.")
        return

    var bus := Engine.get_singleton("AudioEventBus")
    if not bus.has_signal("sound_emitted"):
        push_warning("AudioEventBus has no `sound_emitted` signal. "
            + "Check your AudioEventBus implementation.")
        return

    if not bus.is_connected("sound_emitted", Callable(self, "_on_sound_emitted")):
        bus.connect("sound_emitted", Callable(self, "_on_sound_emitted"))


## AudioEventBus から呼ばれるコールバック
## シグネチャは AudioEventBus 側と合わせること:
##   signal sound_emitted(position: Vector2, volume: float, source: Node)
func _on_sound_emitted(position: Vector2, volume: float, source: Node) -> void:
    # 音量チェック
    if volume < min_volume_threshold:
        return

    # 距離チェック(0 の場合はスキップ)
    if hearing_radius > 0.0:
        var dist := position.distance_to(get_global_position_2d())
        if dist > hearing_radius:
            return

    # 有効な音として記憶
    last_sound_position = position
    last_sound_volume = volume
    last_sound_source = source
    has_pending_sound = true

    # 親ノードにシグナルで通知
    heard_sound.emit(last_sound_position, last_sound_volume, last_sound_source)


## 2D/3D どちらでも動くように、汎用的にグローバル位置を取得
func get_global_position_2d() -> Vector2:
    var n := get_parent()
    if n == null:
        return Vector2.ZERO

    if n is Node2D:
        return n.global_position
    elif n is CharacterBody2D:
        return n.global_position
    elif n is Node3D:
        # 簡易的に XZ 平面に投影
        var gp: Vector3 = n.global_position
        return Vector2(gp.x, gp.z)
    elif n is CharacterBody3D:
        var gp3: Vector3 = n.global_position
        return Vector2(gp3.x, gp3.z)
    else:
        # 位置を持たない親の場合は自分の位置を使う(2D前提)
        if self is Node2D:
            return self.global_position
        return Vector2.ZERO


## 最後に聞こえた音の位置を取得するヘルパー
func get_last_sound_position() -> Vector2:
    return last_sound_position


## 新しい音をまだ処理していないかどうか
func has_new_sound() -> bool:
    return has_pending_sound


## 親ノード側が「音に反応し終わった」ときに呼び出す
func clear_sound() -> void:
    has_pending_sound = false


## デバッグ描画(2Dシーンでのみ有効)
func _draw() -> void:
    if not debug_draw:
        return

    if hearing_radius <= 0.0:
        return

    # Node2D またはその子孫のときだけ描画
    if self is Node2D:
        draw_circle(Vector2.ZERO, hearing_radius, debug_color)


func _process(delta: float) -> void:
    # デバッグ表示更新用
    if debug_draw and (self is Node2D):
        queue_redraw()


## ====== 手動で音を受け取るための汎用メソッド ======
## AudioEventBus を使わず、近くのノードから直接呼んでもOK。
func receive_sound(position: Vector2, volume: float, source: Node) -> void:
    _on_sound_emitted(position, volume, source)

AudioEventBus(プレイヤーが音を発するための簡易バス)例

HearingSensor は「AudioEventBus」というシングルトンから音を受け取る想定にしています。
Autoload(プロジェクト設定 > AutoLoad)で登録して使う前提の、最小限の実装例も載せておきます。

AudioEventBus.gd


extends Node
class_name AudioEventBus

## グローバルに飛ばす「音イベント」
signal sound_emitted(position: Vector2, volume: float, source: Node)

## どこからでも呼べるユーティリティ関数
func emit_sound(position: Vector2, volume: float, source: Node) -> void:
    sound_emitted.emit(position, volume, source)

これを Project Settings > AutoLoad で登録し、
ノード名: AudioEventBus、パス: res://AudioEventBus.gd として追加しておきましょう。


使い方の手順

手順①: AudioEventBus を Autoload に登録する

  1. AudioEventBus.gd をプロジェクト直下などに保存。
  2. Project > Project Settings > AutoLoad タブを開く。
  3. Path に res://AudioEventBus.gd、Node Name に AudioEventBus を入力して「Add」。

これで、どのスクリプトからでも AudioEventBus.emit_sound(...) を呼べるようになります。

手順②: 敵シーンに HearingSensor をアタッチする

例として 2D の敵を想定します。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── HearingSensor (Node)

Enemy.gd(敵本体) の例:


extends CharacterBody2D

@onready var hearing_sensor: HearingSensor = $HearingSensor

enum State { IDLE, PATROL, INVESTIGATE }

var state: State = State.IDLE
var investigate_target: Vector2 = Vector2.ZERO
var move_speed: float = 100.0

func _ready() -> void:
    # HearingSensor からのシグナルを受け取る
    hearing_sensor.heard_sound.connect(_on_heard_sound)


func _physics_process(delta: float) -> void:
    match state:
        State.IDLE:
            _process_idle(delta)
        State.PATROL:
            _process_patrol(delta)
        State.INVESTIGATE:
            _process_investigate(delta)


func _process_idle(delta: float) -> void:
    velocity = Vector2.ZERO
    move_and_slide()


func _process_patrol(delta: float) -> void:
    # パトロール処理は省略(WayPoint など)
    move_and_slide()


func _process_investigate(delta: float) -> void:
    var dir := (investigate_target - global_position)
    if dir.length() < 8.0:
        # 目的地に到達したら元の状態に戻る
        state = State.PATROL
        hearing_sensor.clear_sound()
        return

    dir = dir.normalized()
    velocity = dir * move_speed
    move_and_slide()


## HearingSensor からのコールバック
func _on_heard_sound(sound_pos: Vector2, volume: float, source: Node) -> void:
    investigate_target = sound_pos
    state = State.INVESTIGATE

これで、HearingSensor が音を検知すると、敵は INVESTIGATE 状態に入り、
音のした場所へ移動するようになります。

手順③: プレイヤーが「音」を発するようにする

プレイヤーがジャンプやダッシュをしたときに、AudioEventBus に音イベントを投げます。

Player (CharacterBody2D)
 ├── Sprite2D
 └── CollisionShape2D

Player.gd の一部例:


extends CharacterBody2D

var move_speed: float = 200.0
var jump_velocity: float = -400.0

func _physics_process(delta: float) -> void:
    var input := Vector2.ZERO
    input.x = Input.get_axis("ui_left", "ui_right")

    if input.x != 0.0:
        velocity.x = input.x * move_speed
    else:
        velocity.x = move_toward(velocity.x, 0, move_speed)

    if Input.is_action_just_pressed("ui_accept") and is_on_floor():
        velocity.y = jump_velocity
        _emit_step_sound(0.6) ## ジャンプ音(少し大きめ)

    move_and_slide()

    if is_on_floor() and abs(velocity.x) > 10.0:
        # 歩行中に一定間隔で足音を鳴らすなど
        # ここではサンプルとして常に鳴らす
        _emit_step_sound(0.3)


func _emit_step_sound(volume: float) -> void:
    # 自分の位置に「音」を発生させる
    AudioEventBus.emit_sound(global_position, volume, self)

これで、プレイヤーが移動・ジャンプするたびに音イベントが飛び、
近くにいる敵の HearingSensor がそれを拾って調査に向かうようになります。

手順④: シーン構成を俯瞰してみる

例として、プレイヤー・敵が複数いるステージのシーン構成はこんなイメージです。

MainLevel (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    └── CollisionShape2D
 ├── EnemyGuard1 (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── HearingSensor (Node)
 ├── EnemyGuard2 (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── HearingSensor (Node)
 └── EnemyDog (CharacterBody2D)
      ├── Sprite2D
      ├── CollisionShape2D
      └── HearingSensor (Node)

どの敵も 同じ HearingSensor コンポーネント を付けるだけで「音を聞ける敵」に変身します。
移動ロジックやステートマシンは各敵シーンのスクリプトに閉じているので、
「聴覚まわりだけを修正したい」ときは HearingSensor.gd だけを触ればOKです。


メリットと応用

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

  • 敵クラスの継承ツリーが増えない
    「聴覚を持つ敵」「持たない敵」をクラス継承で分ける必要がなく、
    シーンで HearingSensor ノードを付けるかどうかで切り替えられます。
  • AI ロジックの責務分離
    敵本体は「ステート管理と移動」に集中、HearingSensor は「音検知」に集中。
    それぞれのスクリプトが小さく保てるので、保守が楽になります。
  • レベルデザインの自由度アップ
    hearing_radius や min_volume_threshold をエディタから変えるだけで、
    「耳がいい敵」「鈍感な敵」を簡単に作り分けられます。
  • シーン構造がフラットで見通しが良い
    「聴覚」「視覚」「巡回ルート」「攻撃判定」などを、それぞれコンポーネント化して
    横に並べていくスタイルになるので、ノードツリーがとても読みやすくなります。

応用・改造案: 「最近の音ほど優先する」評価関数を追加する

例えば、敵が複数の音を聞いたときに「より近く・より大きく・より新しい音」を優先するような
スコアリング関数を HearingSensor に追加しても面白いです。

簡単な改造例として、スコアを計算するメソッドを追加してみましょう。


## HearingSensor.gd に追記できる例

var _last_sound_time: float = -INF

func _on_sound_emitted(position: Vector2, volume: float, source: Node) -> void:
    var now := Time.get_ticks_msec() / 1000.0

    if volume < min_volume_threshold:
        return

    var dist := position.distance_to(get_global_position_2d())
    if hearing_radius > 0.0 and dist > hearing_radius:
        return

    # 音の「スコア」を計算(距離が近く、音量が大きく、新しいほど高スコア)
    var score := _calculate_sound_score(dist, volume, now - _last_sound_time)

    # ここでは「新しい音は無条件で上書き」だが、
    # score を比較して「より高スコアの音だけ採用」といった拡張も可能。
    last_sound_position = position
    last_sound_volume = volume
    last_sound_source = source
    _last_sound_time = now
    has_pending_sound = true

    heard_sound.emit(last_sound_position, last_sound_volume, last_sound_source)


func _calculate_sound_score(distance: float, volume: float, time_since_last: float) -> float:
    # 距離・音量・時間を組み合わせた適当な評価関数
    var dist_factor := max(1.0, distance)
    var time_factor := max(0.1, time_since_last)
    return (volume / dist_factor) / time_factor

このように、HearingSensor 側で「どの音を優先するか」のロジックを育てていけば、
敵本体のスクリプトを汚さずに、かなりリッチな聴覚AIを作っていけます。

継承ベースで巨大な Enemy クラスを作るのではなく、
HearingSensor のような小さなコンポーネントを積み上げていく構成を、ぜひ試してみてください。