Unityの学習を進めていくと、つい何でもかんでも Update() に書いてしまいがちですよね。プレイヤーの移動、敵AI、HP管理、エフェクト制御……すべてが1つの巨大スクリプトに集まると、次のような問題が出てきます。
- 処理の責務がごちゃ混ぜになり、バグの原因箇所が追いにくい
- 別プロジェクトや別オブジェクトに流用しづらい
- ちょっとした仕様変更でも広範囲を書き換える必要が出る
敵キャラクターの「聴覚処理」も、その典型です。
「プレイヤーが音を立てたら、その方向に振り向いて調査に行く」という処理を、巨大なAIスクリプトの一部として書いてしまうと、視覚処理や移動処理と絡み合ってすぐにカオスになります。
そこでこの記事では、「音を聞いて、音源の位置へ調査に向かう」だけに責務を限定したコンポーネント HearingSensor(聴覚センサー) を用意して、敵AIの聴覚をスッキリ分離してみましょう。
【Unity】音を聞いて調査に向かうシンプルAI!「HearingSensor」コンポーネント
以下のコンポーネントは、
- プレイヤーなどが発行する「音シグナル(SoundSignal)」を受信
- 音源位置を記憶し、そこへ向かって移動(調査)
- 到達したら「調査完了」として状態をリセット
といった一連の「聴覚ベースの行動」だけを担当します。
移動には NavMeshAgent を使う想定ですが、NavMeshを使わない場合でも Transform ベースの追従に置き換えればOKです。
前提:音シグナルを表すクラス
まずは「どこで、どれくらいの音がしたか」を表す SoundSignal と、その通知を行うシンプルな静的クラス SoundEmitter を定義します。
using System;
using UnityEngine;
/// <summary>音の情報をまとめたデータ構造</summary>
[Serializable]
public class SoundSignal
{
/// <summary>音が発生したワールド座標</summary>
public Vector3 WorldPosition;
/// <summary>音の強さ(半径などの計算に使う)</summary>
public float Loudness;
/// <summary>音を発生させたオブジェクト(プレイヤーなど)</summary>
public GameObject Source;
public SoundSignal(Vector3 worldPosition, float loudness, GameObject source)
{
WorldPosition = worldPosition;
Loudness = loudness;
Source = source;
}
}
/// <summary>
/// グローバルに音シグナルを発行する簡易イベントハブ。
/// 敵の HearingSensor はこのイベントを購読します。
/// </summary>
public static class SoundEmitter
{
/// <summary>音が発生したときに呼ばれるイベント</summary>
public static event Action<SoundSignal> OnSoundEmitted;
/// <summary>任意の場所で音を発生させるためのユーティリティ関数</summary>
public static void EmitSound(Vector3 position, float loudness, GameObject source)
{
var signal = new SoundSignal(position, loudness, source);
OnSoundEmitted?.Invoke(signal);
}
}
SoundEmitter.EmitSound(...) を呼び出せば、シーン内の全ての HearingSensor に音が届くようなイメージです。
本体:HearingSensor コンポーネント
using UnityEngine;
using UnityEngine.AI;
/// <summary>
/// 聴覚センサーコンポーネント。
/// - SoundEmitter から音シグナルを受信
/// - 聞こえる範囲の音だけを採用
/// - 音源の位置へ NavMeshAgent で移動
/// - 到達したら「調査完了」
///
/// 「聴覚による調査」だけを担当し、攻撃や巡回パターンは別コンポーネントに分離するとスッキリします。
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
public class HearingSensor : MonoBehaviour
{
[Header("聴覚パラメータ")]
[SerializeField]
[Tooltip("この距離以内の音だけを聞き取る(音の強さも考慮)")]
private float hearingRadius = 15f;
[SerializeField]
[Tooltip("音がした方向を向くときの回転速度")]
private float turnSpeed = 720f;
[Header("調査挙動")]
[SerializeField]
[Tooltip("音源に十分近づいたとみなす距離")]
private float investigateArrivalDistance = 1.5f;
[SerializeField]
[Tooltip("調査完了後、この秒数だけその場で様子を見る")]
private float investigateWaitTime = 2f;
[Header("デバッグ")]
[SerializeField]
[Tooltip("最後に聞いた音の位置をGizmosで表示するか")]
private bool drawDebugGizmos = true;
// 内部状態
private NavMeshAgent agent;
/// <summary>現在調査中かどうか</summary>
public bool IsInvestigating { get; private set; }
/// <summary>最後に聞いた音のワールド座標(調査ターゲット)</summary>
public Vector3 LastHeardPosition { get; private set; }
private float investigateTimer;
private bool hasInvestigationTarget;
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
// NavMeshAgent は回転を自動制御するかどうかを切り替えられます。
// 聴覚で向きだけ変えたい場合など、必要に応じて調整してください。
agent.updateRotation = true;
}
private void OnEnable()
{
// グローバルな音イベントを購読
SoundEmitter.OnSoundEmitted += HandleSoundEmitted;
}
private void OnDisable()
{
// イベント購読解除(メモリリークや例外防止のため必須)
SoundEmitter.OnSoundEmitted -= HandleSoundEmitted;
}
/// <summary>
/// 音シグナルを受信したときに呼ばれるコールバック。
/// </summary>
private void HandleSoundEmitted(SoundSignal signal)
{
// 自分自身が出した音は無視する(必要なければコメントアウト)
if (signal.Source == gameObject)
{
return;
}
// 音源との距離を計算
float distance = Vector3.Distance(transform.position, signal.WorldPosition);
// 音の強さに応じて実質的な可聴距離を計算(例:Loudness=2なら2倍遠くまで聞こえる)
float effectiveHearingRadius = hearingRadius * signal.Loudness;
// 可聴範囲外なら無視
if (distance > effectiveHearingRadius)
{
return;
}
// 音が聞こえたので、その位置を調査ターゲットに設定
LastHeardPosition = signal.WorldPosition;
hasInvestigationTarget = true;
IsInvestigating = true;
investigateTimer = 0f;
// NavMeshAgent に目的地をセット
if (agent.enabled && agent.isOnNavMesh)
{
agent.SetDestination(LastHeardPosition);
}
// すぐに音の方向を向く(スナップ回転)
Vector3 toSound = (LastHeardPosition - transform.position);
toSound.y = 0f;
if (toSound.sqrMagnitude > 0.001f)
{
Quaternion targetRot = Quaternion.LookRotation(toSound.normalized, Vector3.up);
transform.rotation = targetRot;
}
}
private void Update()
{
if (!IsInvestigating || !hasInvestigationTarget)
{
return;
}
// NavMeshAgent が有効なら、目的地への移動は自動で行われます。
// ここでは「到着したかどうか」の判定と、待機ロジックだけを行います。
// 目的地との距離を計算
float distanceToTarget = Vector3.Distance(transform.position, LastHeardPosition);
// 十分近づいたら「到着」とみなす
if (distanceToTarget <= investigateArrivalDistance)
{
// その場で様子を見る時間をカウント
investigateTimer += Time.deltaTime;
if (investigateTimer >= investigateWaitTime)
{
// 調査完了
IsInvestigating = false;
hasInvestigationTarget = false;
investigateTimer = 0f;
// 必要ならここで「元の巡回ルートに戻る」などのイベントを発行してもOK
// ただし、このコンポーネントは「聴覚による調査」だけに責務を絞るのがおすすめです。
}
}
else
{
// まだ到着していない間、音の方向をゆっくり向く(自然な挙動用)
Vector3 toTarget = LastHeardPosition - transform.position;
toTarget.y = 0f;
if (toTarget.sqrMagnitude > 0.001f)
{
Quaternion targetRot = Quaternion.LookRotation(toTarget.normalized, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation,
targetRot,
turnSpeed * Time.deltaTime
);
}
}
}
/// <summary>
/// デバッグ用に、最後に聞いた音の位置をSceneビューに表示
/// </summary>
private void OnDrawGizmosSelected()
{
if (!drawDebugGizmos)
{
return;
}
// 可聴範囲
Gizmos.color = new Color(0f, 0.5f, 1f, 0.2f);
Gizmos.DrawWireSphere(transform.position, hearingRadius);
// 最後に聞いた音の位置
if (Application.isPlaying && IsInvestigating)
{
Gizmos.color = Color.yellow;
Gizmos.DrawSphere(LastHeardPosition, 0.3f);
Gizmos.DrawLine(transform.position, LastHeardPosition);
}
}
/// <summary>
/// 外部から強制的に「調査中止」させたい場合に呼び出すヘルパー
/// </summary>
public void CancelInvestigation()
{
IsInvestigating = false;
hasInvestigationTarget = false;
investigateTimer = 0f;
// NavMeshAgent の目的地もリセットしたい場合
if (agent.enabled && agent.isOnNavMesh)
{
agent.ResetPath();
}
}
}
おまけ:プレイヤー側で音を出す簡単コンポーネント
「スペースキーを押したら足音を出す」ような、テスト用スクリプトも載せておきます。
using UnityEngine;
/// <summary>
/// キー入力で SoundEmitter に音を発行する簡易テスト用コンポーネント。
/// プレイヤーオブジェクトなどに付けて使います。
/// </summary>
public class PlayerNoiseTester : MonoBehaviour
{
[SerializeField]
[Tooltip("発生させる音の強さ(HearingSensor の hearingRadius に掛け算される)")]
private float loudness = 1f;
[SerializeField]
[Tooltip("何秒ごとに音を出せるか(連打防止)")]
private float noiseCooldown = 0.5f;
private float cooldownTimer;
private void Update()
{
cooldownTimer -= Time.deltaTime;
// スペースキーで音を出す(Input System を使う場合はここを書き換える)
if (Input.GetKeyDown(KeyCode.Space) && cooldownTimer <= 0f)
{
EmitFootstep();
cooldownTimer = noiseCooldown;
}
}
/// <summary>現在位置で音を発生させる</summary>
private void EmitFootstep()
{
Vector3 pos = transform.position;
SoundEmitter.EmitSound(pos, loudness, gameObject);
// デバッグログ
Debug.Log($"[PlayerNoiseTester] 音を発生: pos={pos}, loudness={loudness}");
}
}
使い方の手順
-
敵キャラクターに HearingSensor を追加する
- 敵用のプレハブ(Enemy など)を開く
NavMeshAgentコンポーネントを追加し、移動速度や半径を設定- 上記の
HearingSensorスクリプトをアタッチ HearingRadius(聴覚範囲)やInvestigateArrivalDistanceを好みに調整
-
プレイヤーに PlayerNoiseTester を追加する
- プレイヤーの GameObject に
PlayerNoiseTesterをアタッチ Loudnessを 1〜2 くらいに設定(値が大きいほど遠くまで聞こえる)- ゲーム再生中にスペースキーを押すと、その位置で音シグナルが発生
- プレイヤーの GameObject に
-
NavMesh をベイクしておく
- 地面(Floor)などに
Navigation Staticを設定 - Window > AI > Navigation から NavMesh をベイク
- 敵キャラが NavMesh 上に乗っていることを確認
これで、敵の
HearingSensorはSoundEmitterから届いた音の位置へ NavMesh 経路で移動してくれます。 - 地面(Floor)などに
-
具体的な使用例
-
プレイヤーの足音
CharacterControllerベースのプレイヤーなら、「一定距離歩くごと」「着地したとき」などでSoundEmitter.EmitSound()を呼び出すことで、足音に反応する敵を簡単に実現できます。 -
投げた石(デコイ)
石のプレハブに「着地時に一度だけ音を出すコンポーネント」を付け、OnCollisionEnterでEmitSound()を呼べば、「石を投げた方向に敵が釣られていく」ステルス要素が作れます。 -
アラーム付きの動く床
プレイヤーが乗ると大きな音を立てる床にSoundEmitter.EmitSound()を仕込めば、「誤って乗ると周囲の敵が一斉に集まってくる」トラップギミックも簡単です。
-
プレイヤーの足音
メリットと応用
HearingSensor を使う最大のメリットは、「敵AIの聴覚だけをコンポーネントとして独立させられる」 ことです。
- 巡回AI、追跡AI、攻撃AIなどと分離でき、1つ1つのスクリプトが小さく保てる
- 「聴覚を持つ敵」「聴覚を持たない敵」を、コンポーネントの有無で切り替えられる
- プレハブ化しておけば、新しい敵を作るときにドラッグ&ドロップで聴覚を付与できる
- レベルデザイン側は「どこでどれくらいの音を出すか」だけを考えればよく、AIロジックを触らずにギミックを追加できる
たとえば、ボス敵には HearingSensor を付けず、雑魚敵にだけ付けることで、「音に釣られるのは雑魚だけ」というゲーム性を簡単に作れます。
また、「特定の音種だけに反応する聴覚センサー」や「聞こえた音の履歴を保持して行動パターンを変える」などにも発展させやすいです。
改造案:音の「種類」によるフィルタリング
例えば「足音には反応するけど、味方の銃声には反応しない」といった区別をしたい場合、SoundSignal に種類を追加し、HearingSensor 側でフィルタリングするのが簡単です。
以下は、HearingSensor に「特定の音種だけを受け取る」フィルタ処理を追加する一例です(イメージ用の1メソッド)。
/// <summary>
/// 受信した音シグナルが、このセンサーにとって有効かどうかを判定する例。
/// SoundSignal に enum SoundType などを追加して、ここで条件分岐するとよいです。
/// </summary>
private bool IsAcceptableSound(SoundSignal signal)
{
// 例: 味方が出した音は無視したい場合
// (味方のタグを "Ally" にしておく想定)
if (signal.Source != null && signal.Source.CompareTag("Ally"))
{
return false;
}
// ここに「音の種類」や「時間帯」などの条件を増やしていけます。
return true;
}
実際には HandleSoundEmitted の冒頭で if (!IsAcceptableSound(signal)) return; のように呼び出してあげればOKです。
このように、小さな関数単位で責務を切り出していくと、聴覚ロジックもどんどん拡張しやすくなりますね。




