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 に登録する
AudioEventBus.gdをプロジェクト直下などに保存。- Project > Project Settings > AutoLoad タブを開く。
- 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 のような小さなコンポーネントを積み上げていく構成を、ぜひ試してみてください。
