Unityの初心者~中級者のあいだで一番よくあるパターンが、敵AIやプレイヤー制御を「とりあえず全部Updateに書く」やり方ですよね。
最初は動くので満足できますが、だんだんと
- 「プレイヤーを追う処理」
- 「見失ったときの処理」
- 「攻撃する処理」
- 「アニメーション切り替え」
…といったロジックが全部1つのスクリプトに積み上がっていき、気づいたらGodクラス化してしまいます。
そこでこの記事では、「見失った位置まで移動して、周囲をキョロキョロ見回す」という1つの責務だけに絞ったコンポーネント
「InvestigatePos(不審点調査)」を用意して、敵AIの「捜索ステート」をコンポーネントとして切り出す方法を紹介します。
追跡や攻撃などの他の処理は別コンポーネントに分け、このコンポーネントは
「不審な位置を調査する」ことだけに集中させる構成を目指します。
【Unity】見失った場所を調査せよ!「InvestigatePos」コンポーネント
このコンポーネントは、ざっくり言うと次のような動きをします。
- 外部から「調査したい位置(最後にプレイヤーを見た位置など)」をセットされる
- その位置まで移動する
- 到着したら、一定時間「首を振るように」周囲をキョロキョロ見回す
- 調査が終わったら、コールバックで「調査完了」を通知する
敵AIのステートマシンやビヘイビアツリーなどから
「見失ったからInvestigatePosを有効にする」といった使い方を想定しています。
フルコード:InvestigatePos.cs
using UnityEngine;
using System;
using System.Collections;
namespace Samples.AI
{
/// <summary>
/// 不審点(最後にプレイヤーを見た位置など)まで移動し、
/// 周囲をキョロキョロ見回すためのコンポーネント。
///
/// ・「どこを調査するか」を外部から指定する
/// ・移動と首振り(回転)アニメーションをここに閉じ込める
/// ・調査完了をイベントで通知する
///
/// という単一責務を担当させています。
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class InvestigatePos : MonoBehaviour
{
[Header("移動設定")]
[SerializeField]
private float moveSpeed = 3.0f; // 調査ポイントまで歩く速度
[SerializeField]
private float arriveDistance = 0.2f; // この距離以内に入ったら「到着」とみなす
[Header("首振り(見回し)設定")]
[SerializeField]
private float lookAroundDuration = 3.0f; // 見回す合計時間(秒)
[SerializeField]
private float lookAngle = 45.0f; // 左右に振る最大角度(度)
[SerializeField]
private float lookSpeed = 2.0f; // 首振りの速さ(大きいほど速く往復)
[Header("デバッグ表示")]
[SerializeField]
private Color gizmoColor = Color.yellow;
[SerializeField]
private float gizmoRadius = 0.25f;
// 内部状態
private CharacterController characterController;
// 調査対象のワールド座標
private Vector3 targetPosition;
private bool hasTarget = false;
// 調査中フラグ
private bool isInvestigating = false;
// 見回し中のコルーチン参照
private Coroutine lookAroundCoroutine;
// 調査完了を外部に通知するイベント
public event Action OnInvestigationCompleted;
// もともとの向き(見回し後に戻す用)
private Quaternion initialRotation;
private void Awake()
{
characterController = GetComponent<CharacterController>();
}
private void Update()
{
if (!isInvestigating || !hasTarget)
{
return;
}
// まだ目的地に到着していなければ、そこまで移動する
MoveTowardsTarget();
// 目的地に到着したら見回し開始(1回だけ)
if (HasArrivedToTarget())
{
// もう見回しを始めているなら何もしない
if (lookAroundCoroutine == null)
{
// 移動は止めるため、ターゲットフラグを一旦オフにする
hasTarget = false;
lookAroundCoroutine = StartCoroutine(LookAroundRoutine());
}
}
}
/// <summary>
/// 外部から「この位置を調査して」と指示するためのメソッド。
/// 敵AIの別コンポーネントから呼び出す想定です。
/// </summary>
/// <param name="worldPos">調査したいワールド座標</param>
public void StartInvestigation(Vector3 worldPos)
{
targetPosition = worldPos;
hasTarget = true;
isInvestigating = true;
// 途中で呼ばれた場合に備えて、見回しコルーチンを止めておく
if (lookAroundCoroutine != null)
{
StopCoroutine(lookAroundCoroutine);
lookAroundCoroutine = null;
}
}
/// <summary>
/// 調査を強制的に中断したい場合に呼ぶ。
/// 例:プレイヤーを再び視認したときなど。
/// </summary>
public void CancelInvestigation()
{
isInvestigating = false;
hasTarget = false;
if (lookAroundCoroutine != null)
{
StopCoroutine(lookAroundCoroutine);
lookAroundCoroutine = null;
}
}
/// <summary>
/// 現在調査中かどうかを返す。
/// </summary>
public bool IsInvestigating => isInvestigating;
/// <summary>
/// ターゲット位置へ移動する処理。
/// CharacterController を使って地面に沿って移動します。
/// </summary>
private void MoveTowardsTarget()
{
// 水平方向のみで距離を計算したい場合は y を揃える
Vector3 current = transform.position;
Vector3 flatTarget = new Vector3(targetPosition.x, current.y, targetPosition.z);
Vector3 dir = (flatTarget - current);
float distance = dir.magnitude;
if (distance <= 0.0001f)
{
return;
}
dir.Normalize();
// 移動量(重力はここでは扱わない。別コンポーネントで管理してもOK)
Vector3 move = dir * moveSpeed * Time.deltaTime;
characterController.Move(move);
// 移動方向を向く(自然な見た目用)
if (dir.sqrMagnitude > 0.0001f)
{
Quaternion lookRot = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.Slerp(transform.rotation, lookRot, 10f * Time.deltaTime);
}
}
/// <summary>
/// 目的地に到着したかどうか。
/// </summary>
private bool HasArrivedToTarget()
{
Vector3 current = transform.position;
Vector3 flatTarget = new Vector3(targetPosition.x, current.y, targetPosition.z);
float distance = Vector3.Distance(current, flatTarget);
return distance <= arriveDistance;
}
/// <summary>
/// 目的地に到着したあと、一定時間キョロキョロ見回すコルーチン。
/// </summary>
private IEnumerator LookAroundRoutine()
{
isInvestigating = true; // 念のため
float elapsed = 0f;
initialRotation = transform.rotation;
// 首振りの原点となるY軸回転角
float baseY = initialRotation.eulerAngles.y;
while (elapsed < lookAroundDuration)
{
elapsed += Time.deltaTime;
// 0~1の時間正規化
float t = elapsed / lookAroundDuration;
// -1~+1 を往復するような値を作る(Mathf.Sinを利用)
float wave = Mathf.Sin(Time.time * lookSpeed);
// wave=-1~+1 を lookAngle にスケールして左右に振る
float offsetY = wave * lookAngle;
// 新しいY角度
float newY = baseY + offsetY;
Vector3 euler = initialRotation.eulerAngles;
euler.y = newY;
transform.rotation = Quaternion.Euler(euler);
yield return null;
}
// 最後に元の向きに戻す
transform.rotation = initialRotation;
isInvestigating = false;
lookAroundCoroutine = null;
// 調査完了を通知
OnInvestigationCompleted?.Invoke();
}
/// <summary>
/// Sceneビューで調査ポイントを分かりやすくするためのGizmo描画。
/// </summary>
private void OnDrawGizmosSelected()
{
if (!hasTarget)
{
return;
}
Gizmos.color = gizmoColor;
Gizmos.DrawWireSphere(targetPosition, gizmoRadius);
}
}
}
使い方の手順
ここでは「敵キャラクターがプレイヤーを見失ったとき、その最後に見た位置まで行ってキョロキョロする」ケースを例にします。
手順①:敵プレハブにコンポーネントを追加
- 敵キャラクターのプレハブ(またはシーン上のGameObject)を選択
- CharacterController コンポーネントをアタッチ(まだ付いていなければ)
- InvestigatePos.cs を同じオブジェクトにアタッチ
- インスペクターで
- Move Speed(移動速度)
- Arrive Distance(到着判定距離)
- Look Around Duration(見回し時間)
- Look Angle(左右に振る角度)
を好みに合わせて調整
手順②:プレイヤーを見失った位置を渡すスクリプトを用意
敵の視界判定などを行っている別コンポーネントから、StartInvestigation を呼び出します。
ここでは簡単な例として、「スペースキーを押したら、プレイヤーの現在位置を不審点として調査させる」サンプルを示します。
using UnityEngine;
namespace Samples.AI
{
/// <summary>
/// デモ用:スペースキーを押した位置を「不審点」として
/// InvestigatePos に渡すサンプル。
/// 実際のゲームでは「プレイヤーを見失った瞬間の位置」を渡すと良いです。
/// </summary>
public class InvestigateDemoController : MonoBehaviour
{
[SerializeField]
private InvestigatePos investigatePos;
[SerializeField]
private Transform playerTransform;
private void Reset()
{
// 同じオブジェクトに InvestigatePos がある場合、自動取得
if (investigatePos == null)
{
investigatePos = GetComponent<InvestigatePos>();
}
}
private void Update()
{
// 非推奨の旧Inputですが、サンプル簡略化のため使用
// 実プロジェクトでは新Input Systemに置き換えてください。
if (Input.GetKeyDown(KeyCode.Space))
{
if (playerTransform != null && investigatePos != null)
{
Vector3 lastSeenPos = playerTransform.position;
investigatePos.StartInvestigation(lastSeenPos);
Debug.Log($"Investigate start at {lastSeenPos}");
}
}
}
}
}
このサンプルでは、敵がプレイヤーを見失ったかどうかなどの本格的な判定は行っていませんが、
「外部から座標を渡して、InvestigatePosに任せる」という使い方のイメージはつかめるはずです。
手順③:調査完了を受け取って次の行動につなげる
InvestigatePos は、見回しが終わったタイミングで OnInvestigationCompleted イベントを発火します。
これを利用して、「巡回ルートに戻る」「別のステートに遷移する」といった処理を組み合わせましょう。
using UnityEngine;
namespace Samples.AI
{
/// <summary>
/// 調査完了を受け取って、次の行動に切り替える簡単なサンプル。
/// 実際にはステートマシンやアニメーション切り替えにつなげると良いです。
/// </summary>
public class InvestigateListener : MonoBehaviour
{
[SerializeField]
private InvestigatePos investigatePos;
private void Reset()
{
if (investigatePos == null)
{
investigatePos = GetComponent<InvestigatePos>();
}
}
private void OnEnable()
{
if (investigatePos != null)
{
investigatePos.OnInvestigationCompleted += HandleInvestigationCompleted;
}
}
private void OnDisable()
{
if (investigatePos != null)
{
investigatePos.OnInvestigationCompleted -= HandleInvestigationCompleted;
}
}
private void HandleInvestigationCompleted()
{
Debug.Log("調査完了!次の行動に移ります。");
// TODO:
// ここで「巡回ステートに戻す」「警戒度を下げる」などを実装
}
}
}
手順④:応用例
- 敵AI:プレイヤーを視界から見失った瞬間、その位置を
StartInvestigationで渡す - 警備ドローン:センサーが反応した位置に飛んでいき、一定時間その場をスキャンする演出
- 動く監視カメラ:プレイヤーを見失った向きの先をしばらく探索する、など
これらはすべて、「不審点調査のロジックは InvestigatePos に任せる」という構成で、他のロジックときれいに分離できます。
メリットと応用
メリット①:AIロジックの責務分離がしやすい
追跡・攻撃・巡回・調査…といったAIの各フェーズを、
それぞれ別コンポーネントとして分けやすくなります。
ChasePlayer:プレイヤーを追いかけるだけPatrolRoute:巡回ポイントを回るだけInvestigatePos:不審点を調査するだけ
といった具合に分解しておけば、
「この敵は巡回+調査だけ」「この敵は追跡+攻撃+調査」など、プレハブ単位で行動パターンを組み合わせることが簡単になります。
メリット②:レベルデザインが楽になる
InvestigatePos は「座標さえ渡せば動く」ように作ってあるので、
レベルデザイナーは
- トリガーゾーンに入ったらその位置を調査させる
- 爆発音の発生位置を調査させる
- プレイヤーの残した足跡の最後の位置を調査させる
といったギミックを、座標を1つ渡すだけで簡単に組み込めます。
スクリプトの中身をいじらず、コンポーネントの組み合わせとパラメータ調整だけでバリエーションを出せるのが大きな利点です。
メリット③:アニメーションやサウンドの差し替えも容易
見回し中のアニメーションやサウンドを追加したくなった場合も、
InvestigatePos の内部、もしくは連携する別コンポーネントで完結させられます。
例えば、見回し開始時に「うーん?」というボイスを鳴らすコンポーネントを追加するだけで、
他のAIロジックには一切手を触れずに演出を強化できます。
改造案:見回し中にランダムな高さも少し変える
首を左右に振るだけでなく、少しだけ上下にも揺らして「覗き込んでいる」感じを出したい場合の改造例です。
下記のようなメソッドを追加し、LookAroundRoutine 内で呼び出すようにすればOKです。
/// <summary>
/// Y軸回転に加えて、少しだけ上下にも揺らす見回し処理の一例。
/// lookAroundRoutine から呼ぶことを想定。
/// </summary>
private void ApplyFancyLook(float baseY, float elapsed)
{
// 左右の首振り(既存と同じロジック)
float wave = Mathf.Sin(Time.time * lookSpeed);
float offsetY = wave * lookAngle;
// 上下方向の小さな揺れ(覗き込むような動き)
float verticalWave = Mathf.Sin(Time.time * (lookSpeed * 1.5f));
float offsetX = verticalWave * 5.0f; // 上下5度だけ揺らす
Vector3 euler = initialRotation.eulerAngles;
euler.y = baseY + offsetY;
euler.x = euler.x + offsetX; // もとのXに対して加算
transform.rotation = Quaternion.Euler(euler);
}
このように、小さな改造を別メソッドとして切り出しておくと、
「派手な見回し」「控えめな見回し」などをメソッド単位で差し替えられるようになり、
さらにコンポーネント指向らしい設計に近づけます。
