Unityを触り始めた頃、「とりあえず敵の処理は全部このEnemy.csのUpdateに書いちゃえ!」となりがちですよね。
視界判定、移動、攻撃、アニメーション、HP管理……すべてを1つのスクリプトに押し込むと、少し仕様変更が入っただけでコードが壊れやすくなり、デバッグも地獄になります。

そこで今回は、「敵の視界」だけに責務を絞った小さなコンポーネントを作っていきます。
敵キャラの前方に扇形(コーン状)の視界エリアを展開し、壁に遮られていない場合だけプレイヤーを「発見」するコンポーネントです。

移動ロジックや攻撃ロジックとは分離しておき、
見えているかどうか」だけを教えてくれる VisionCone コンポーネントを用意しておくと、
プレハブの再利用性も上がり、挙動のデバッグもかなり楽になります。

【Unity】壁越しは見えない見張りを作る!「VisionCone」コンポーネント

ここでは Unity6(C#)で動く、2D用の視界コーン判定コンポーネントを実装していきます。
物理演算は 2D(Rigidbody2D / Collider2D / Physics2D) を使用します。

コンポーネントの仕様

  • 敵キャラの「前方」にだけ視界を持つ。
  • 視界は「半径」と「角度」で決まる扇形(コーン状)。
  • プレイヤーが視界コーン内にいても、壁(Obstacleレイヤー)に遮られていたら見えない
  • 「見つけた / 見失った」タイミングでイベント(UnityEvent)を呼べる。

フルコード


using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;

/// <summary>
/// 2D用の視界コーンコンポーネント。
/// 敵の前方に扇形の視界を持ち、壁に遮られていないターゲットだけを「発見」とみなす。
/// 
/// 想定環境:
/// - 2Dプロジェクト
/// - 敵オブジェクトにこのコンポーネントをアタッチ
/// - プレイヤーや見つけたい対象には、指定レイヤーとCollider2Dを設定
/// - 壁などの障害物には、ObstacleレイヤーとCollider2Dを設定
/// </summary>
[DisallowMultipleComponent]
public class VisionCone : MonoBehaviour
{
    [Header("視界設定")]
    [Tooltip("視界の最大距離(半径)")]
    [SerializeField] private float viewRadius = 5f;

    [Tooltip("視界の角度(度数法)。例: 90 なら左右45度ずつ")]
    [Range(0f, 360f)]
    [SerializeField] private float viewAngle = 90f;

    [Tooltip("視界の向き。通常は Transform の右方向を前方とする 2D 横スクロールでは Vector2.right を指定")]
    [SerializeField] private Vector2 localForward = Vector2.right;

    [Header("レイヤー設定")]
    [Tooltip("視界の対象(プレイヤーなど)が乗っているレイヤー")]
    [SerializeField] private LayerMask targetLayer;

    [Tooltip("視界を遮る障害物(壁など)のレイヤー")]
    [SerializeField] private LayerMask obstacleLayer;

    [Header("検出設定")]
    [Tooltip("1フレームでPhysics2D.OverlapCircleNonAllocに使う最大バッファサイズ")]
    [SerializeField] private int maxTargetsPerScan = 16;

    [Tooltip("視界チェックを行う間隔(秒)。0にすると毎フレームチェック")]
    [SerializeField] private float scanInterval = 0.1f;

    [Header("イベント")]
    [Tooltip("新しくターゲットを発見したときに呼ばれる")]
    [SerializeField] private UnityEvent<Transform> onTargetSpotted;

    [Tooltip("最後のターゲットを見失ったときに呼ばれる")]
    [SerializeField] private UnityEvent onAllTargetsLost;

    // 内部状態
    private readonly List<Transform> visibleTargets = new List<Transform>();
    private float scanTimer;

    // OverlapCircleNonAlloc 用のバッファ
    private Collider2D[] overlapResults;

    /// <summary>
    /// 現在視界内にいて、かつ壁に遮られていないターゲット一覧を返す。
    /// 外部から読み取り専用で使うことを想定。
    /// </summary>
    public IReadOnlyList<Transform> VisibleTargets => visibleTargets;

    private void Awake()
    {
        // バッファを初期化
        overlapResults = new Collider2D[maxTargetsPerScan];
    }

    private void Update()
    {
        // 一定間隔でスキャンする(scanInterval <= 0 の場合は毎フレーム)
        scanTimer += Time.deltaTime;
        if (scanInterval > 0f && scanTimer < scanInterval)
        {
            return;
        }

        scanTimer = 0f;
        ScanForTargets();
    }

    /// <summary>
    /// 視界内のターゲットを探索し、内部リストとイベントを更新する。
    /// </summary>
    private void ScanForTargets()
    {
        // 以前の結果をコピーしておき、差分検出に使う
        var previousVisible = new List<Transform>(visibleTargets);
        visibleTargets.Clear();

        Vector2 origin = transform.position;

        // 半径内のターゲット候補をまとめて取得
        int hitCount = Physics2D.OverlapCircleNonAlloc(
            origin,
            viewRadius,
            overlapResults,
            targetLayer
        );

        // forward ベクトル(ワールド空間)を算出
        Vector2 worldForward = transform.TransformDirection(localForward).normalized;

        for (int i = 0; i < hitCount; i++)
        {
            Collider2D col = overlapResults[i];
            if (col == null) continue;

            Transform targetTransform = col.transform;
            Vector2 dirToTarget = ((Vector2)targetTransform.position - origin);
            float distanceToTarget = dirToTarget.magnitude;

            if (distanceToTarget <= 0.0001f)
            {
                // 同一位置にいる場合などはスキップ
                continue;
            }

            Vector2 dirToTargetNormalized = dirToTarget / distanceToTarget;

            // 扇形の角度判定
            float angleToTarget = Vector2.Angle(worldForward, dirToTargetNormalized);
            if (angleToTarget > viewAngle * 0.5f)
            {
                // 視界角度の外なのでスキップ
                continue;
            }

            // 障害物による遮蔽判定(Raycast)
            RaycastHit2D hit = Physics2D.Raycast(
                origin,
                dirToTargetNormalized,
                distanceToTarget,
                obstacleLayer
            );

            if (hit.collider != null)
            {
                // 壁に遮られているので見えない
                continue;
            }

            // ここまで来たら「視界コーン内 & 壁に遮られていない」= 見えている
            visibleTargets.Add(targetTransform);

            // 以前は見えていなかったターゲットなら、「発見」イベントを発火
            if (!previousVisible.Contains(targetTransform))
            {
                onTargetSpotted?.Invoke(targetTransform);
            }
        }

        // すべて見失った場合は onAllTargetsLost を発火
        if (previousVisible.Count > 0 && visibleTargets.Count == 0)
        {
            onAllTargetsLost?.Invoke();
        }
    }

    /// <summary>
    /// 指定したTransformが現在見えているかどうかを返すヘルパー。
    /// AI側から「プレイヤーは見えている?」と問い合わせる用途などに便利。
    /// </summary>
    public bool IsVisible(Transform target)
    {
        return visibleTargets.Contains(target);
    }

    #region デバッグ描画

    [Header("デバッグ描画")]
    [SerializeField] private bool drawGizmos = true;
    [SerializeField] private Color viewAreaColor = new Color(1f, 1f, 0f, 0.15f);
    [SerializeField] private Color viewBorderColor = Color.yellow;
    [SerializeField] private Color visibleTargetColor = Color.red;

    private void OnDrawGizmosSelected()
    {
        if (!drawGizmos) return;

        Vector3 origin = transform.position;
        Vector3 forward3 = transform.TransformDirection(localForward.normalized);

        // 扇形の左右の境界ベクトル
        float halfAngle = viewAngle * 0.5f;
        Quaternion leftRot = Quaternion.AngleAxis(-halfAngle, Vector3.forward);
        Quaternion rightRot = Quaternion.AngleAxis(halfAngle, Vector3.forward);

        Vector3 leftDir = leftRot * forward3;
        Vector3 rightDir = rightRot * forward3;

        // 扇形の塗りつぶしっぽい描画(簡易)
        Gizmos.color = viewAreaColor;
        const int segmentCount = 20; // 扇形の分割数
        float step = viewAngle / segmentCount;

        Vector3 prevDir = leftDir;
        for (int i = 1; i <= segmentCount; i++)
        {
            float angle = -halfAngle + step * i;
            Quaternion rot = Quaternion.AngleAxis(angle, Vector3.forward);
            Vector3 nextDir = rot * forward3;

            Vector3 p0 = origin;
            Vector3 p1 = origin + prevDir * viewRadius;
            Vector3 p2 = origin + nextDir * viewRadius;

            Gizmos.DrawLine(p0, p1);
            Gizmos.DrawLine(p1, p2);
            Gizmos.DrawLine(p2, p0);

            prevDir = nextDir;
        }

        // 境界線
        Gizmos.color = viewBorderColor;
        Gizmos.DrawLine(origin, origin + leftDir * viewRadius);
        Gizmos.DrawLine(origin, origin + rightDir * viewRadius);

        // 見えているターゲットの位置を描画(再生中のみ)
        if (Application.isPlaying && visibleTargets != null)
        {
            Gizmos.color = visibleTargetColor;
            foreach (var t in visibleTargets)
            {
                if (t == null) continue;
                Gizmos.DrawSphere(t.position, 0.1f);
            }
        }
    }

    #endregion
}

使い方の手順

  1. シーンのレイヤーを整理する
    • メニューの「Edit > Project Settings… > Tags and Layers」を開く。
    • Layerに例えば「Player」「Enemy」「Obstacle」などを追加する。
    • プレイヤーのGameObjectに「Player」レイヤー、壁や床などの障害物に「Obstacle」レイヤーを設定する。
  2. 敵プレハブに VisionCone をアタッチする
    • 敵キャラのGameObject(例: Enemy)を選択。
    • 「Add Component」から VisionCone を追加。
    • インスペクターで以下を設定する:
      • View Radius: 敵の視界の届く距離(例: 6)。
      • View Angle: 左右の視野角(例: 90)。
      • Local Forward: 前方方向。
        横スクロールで「右を向いているスプライト」の場合は (1, 0)(デフォルトのままでOK)。
      • Target Layer: 「Player」レイヤーをチェック。
      • Obstacle Layer: 「Obstacle」レイヤーをチェック。
      • Scan Interval: 0.05~0.2 くらいにすると負荷が抑えられます。
  3. イベントで挙動をつなぐ
    例として、「プレイヤーを見つけたら追いかける」「見失ったら待機に戻る」といったAIを作る場合:
    • 敵のGameObjectに、EnemyAIController のようなスクリプトを別途用意しておく。
    • VisionCone のインスペクターで:
      • On Target Spotted に + ボタンでイベントを追加し、
        対象に敵のGameObjectをドラッグして、EnemyAIController.OnTargetSpotted(Transform) のようなメソッドを指定。
      • On All Targets Lost にも同様に、EnemyAIController.OnAllTargetsLost() などを割り当てる。
    • これで、VisionCone は「見える / 見えない」の判定だけに集中し、
      実際の行動(追跡・巡回・待機)は AI コンポーネント側に任せられます。
  4. 具体的な使用例
    • プレイヤーを見張る敵兵
      2Dステージで、敵兵が一定方向を向いて立っている。
      プレイヤーが視界コーンに入ると追跡を開始し、壁の裏に隠れると追跡をやめる。
    • 移動する監視ドローン
      ドローンの移動は別コンポーネント(パトロール用スクリプト)に任せ、
      VisionCone は常にドローンの前方を監視。見つけた瞬間にアラート用のサイレンを鳴らす。
    • 動く床の「検知センサー」
      動く床(エレベーター)が、プレイヤーが視界コーン内に入ったら動き出す、
      というギミックにも流用できます。ターゲットレイヤーを「Player」、障害物を「Default」などに設定するだけでOKです。

メリットと応用

VisionCone を敵やギミックごとにアタッチしておくと、「見えるかどうか」のロジックを1か所に集約できます。

  • プレハブごとに視界半径・角度・向きを変えられるので、レベルデザインがとても楽
    • 見張りAは視野が広いけど短い、見張りBは視野が狭いけど遠くまで見える、など。
  • AI側のスクリプトは「見えているかどうか」を問い合わせるだけで済むので、
    AIロジックと物理判定ロジックをきれいに分離できます。
  • 壁判定もコンポーネント内に閉じているため、
    「Raycastの書き方どこだっけ?」と毎回悩まずに済みます。

さらに応用として、視界に入っている時間をカウントして、一定時間見続けたら「完全発見」扱いにするといったステルスゲーム的な挙動も簡単に追加できます。

例えば、以下のようなメソッドを VisionCone に追加して、
AI側から「どれくらい見られているか」を問い合わせる、といった改造ができます。


    // --- VisionCone クラス内の改造案メソッド例 ---

    /// <summary>
    /// 指定ターゲットが視界に入っている割合(0~1)を返す想定のメソッド。
    /// 実装例としては、別途 Dictionary<Transform, float> で「見られている時間」を管理し、
    /// 最大時間で割って 0~1 に正規化して返すような形になります。
    /// </summary>
    public float GetVisibilityRatio(Transform target, float maxLockOnTime)
    {
        // ここではサンプルとして「今見えているなら1、見えていなければ0」を返す簡易版。
        // 実際には、Update 内でターゲットごとのタイマーを増減させる実装に差し替えましょう。
        return IsVisible(target) ? 1f : 0f;
    }

このように、VisionCone は「視界」という1つの責務だけを担当させておくと、
後から「発見までのラグ」「視界の点滅」「音でごまかす」などの要素を足していくのも簡単になります。
ぜひ敵AIやギミックを作るときの「標準パーツ」として育てていってみてください。