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
}
使い方の手順
-
シーンのレイヤーを整理する
- メニューの「Edit > Project Settings… > Tags and Layers」を開く。
- Layerに例えば「Player」「Enemy」「Obstacle」などを追加する。
- プレイヤーのGameObjectに「Player」レイヤー、壁や床などの障害物に「Obstacle」レイヤーを設定する。
-
敵プレハブに 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 くらいにすると負荷が抑えられます。
-
イベントで挙動をつなぐ
例として、「プレイヤーを見つけたら追いかける」「見失ったら待機に戻る」といったAIを作る場合:- 敵のGameObjectに、
EnemyAIControllerのようなスクリプトを別途用意しておく。 VisionConeのインスペクターで:On Target Spottedに + ボタンでイベントを追加し、
対象に敵のGameObjectをドラッグして、EnemyAIController.OnTargetSpotted(Transform)のようなメソッドを指定。On All Targets Lostにも同様に、EnemyAIController.OnAllTargetsLost()などを割り当てる。
- これで、VisionCone は「見える / 見えない」の判定だけに集中し、
実際の行動(追跡・巡回・待機)は AI コンポーネント側に任せられます。
- 敵のGameObjectに、
-
具体的な使用例
- プレイヤーを見張る敵兵
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やギミックを作るときの「標準パーツ」として育てていってみてください。
