Unityの学習を進めていくと、つい何でもかんでも Update() に書いてしまいがちですよね。プレイヤーの移動、ジャンプ、敵の探索、UIの更新…すべて1つのスクリプトに詰め込むと、いつの間にか「巨大なGodクラス」が出来上がってしまいます。

そうなると、

  • ちょっとした仕様変更でも影響範囲が読めない
  • 別プロジェクトで再利用しづらい
  • バグが起きたときに原因箇所を特定しにくい

といった問題が一気に噴出します。

そこで今回は、「自動照準(Auto Aim)」の機能だけを切り出した小さなコンポーネントを作ってみましょう。敵のグループを指定し、一定範囲内にいる敵の中から最も近いターゲットを探し、その方向ベクトルを計算するコンポーネントです。

プレイヤーの移動ロジックとは分離されているので、

  • プレイヤー
  • タレット(砲台)
  • 自動で旋回する敵

など、さまざまなオブジェクトにそのまま使い回せるようになります。

【Unity】敵を自動サーチして狙い撃ち!「AutoAim」コンポーネント

フルソースコード


using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 自動照準コンポーネント
/// - 指定したLayerに属する敵を範囲検索し、最も近い敵の方向を算出します。
/// - 「どこを向くか」だけを担当し、「どう動くか」は別コンポーネントに任せる設計です。
/// </summary>
[DisallowMultipleComponent]
public class AutoAim : MonoBehaviour
{
    // ====== 設定項目 ======

    [Header("ターゲット検出設定")]

    [SerializeField]
    [Tooltip("敵が所属するLayerを指定します。複数LayerをまとめたLayerMaskでもOKです。")]
    private LayerMask targetLayerMask;

    [SerializeField]
    [Tooltip("探索する最大距離(半径)。この範囲内の敵だけを検出します。")]
    private float searchRadius = 10f;

    [SerializeField]
    [Tooltip("視野角(度)。0〜360。例えば90なら前方45度の扇形を探索します。360なら全方向。")]
    private float viewAngle = 360f;

    [SerializeField]
    [Tooltip("ターゲットの中心位置として使うローカルオフセット。頭の位置などを狙いたい場合に調整します。")]
    private Vector3 targetOffset = Vector3.zero;

    [Header("遮蔽物チェック")]

    [SerializeField]
    [Tooltip("ターゲットとの間に遮蔽物があるかをチェックするLayerMask。壁などを指定します。")]
    private LayerMask obstacleLayerMask;

    [SerializeField]
    [Tooltip("遮蔽物チェックを行うかどうか。falseにすると壁越しでもロックオンします。")]
    private bool checkObstacles = true;

    [Header("更新頻度")]

    [SerializeField]
    [Tooltip("ターゲット探索を行う間隔(秒)。0にすると毎フレーム探索します。")]
    private float searchInterval = 0.1f;

    // ====== 公開情報(読み取り専用プロパティ) ======

    /// <summary>現在ロックオンしているターゲット(いなければ null)</summary>
    public Transform CurrentTarget { get; private set; }

    /// <summary>現在ロックオンしているターゲットへの方向ベクトル(ターゲットがいなければ Vector3.zero)</summary>
    public Vector3 CurrentDirection { get; private set; }

    /// <summary>現在ロックオンしているターゲットまでの距離(ターゲットがいなければ Mathf.Infinity)</summary>
    public float CurrentDistance { get; private set; } = Mathf.Infinity;

    // ====== 内部状態 ======

    private float _timeSinceLastSearch = 0f;
    private readonly Collider[] _overlapResults = new Collider[32]; // 一時バッファ: GC削減用

    private void Awake()
    {
        // 初期状態をクリア
        ClearTarget();
    }

    private void Update()
    {
        // 検索間隔が0以下なら毎フレーム探索
        if (searchInterval <= 0f)
        {
            SearchTarget();
            return;
        }

        // 経過時間を積算して、一定間隔ごとに探索
        _timeSinceLastSearch += Time.deltaTime;
        if (_timeSinceLastSearch >= searchInterval)
        {
            _timeSinceLastSearch = 0f;
            SearchTarget();
        }
    }

    /// <summary>
    /// ターゲット探索のメイン処理。
    /// 一定半径内のコライダーを取得し、最も近い有効なターゲットを選びます。
    /// </summary>
    private void SearchTarget()
    {
        Vector3 origin = transform.position;

        // OverlapSphereNonAllocでGCを抑えつつ、半径内のコライダーを取得
        int hitCount = Physics.OverlapSphereNonAlloc(
            origin,
            searchRadius,
            _overlapResults,
            targetLayerMask,
            QueryTriggerInteraction.Ignore // 必要に応じて変更
        );

        Transform bestTarget = null;
        float bestDistance = Mathf.Infinity;
        Vector3 bestDirection = Vector3.zero;

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

            Transform candidate = col.transform;

            // 自分自身はスキップ
            if (candidate == transform) continue;

            Vector3 candidatePosition = candidate.position + targetOffset;
            Vector3 dir = candidatePosition - origin;
            float distance = dir.magnitude;

            if (distance <= 0f) continue;

            // 視野角チェック
            if (!IsWithinViewAngle(dir))
            {
                continue;
            }

            // 遮蔽物チェック
            if (checkObstacles && IsBlockedByObstacle(origin, candidatePosition, distance))
            {
                continue;
            }

            // 最も近いターゲットを更新
            if (distance < bestDistance)
            {
                bestDistance = distance;
                bestTarget = candidate;
                bestDirection = dir.normalized;
            }
        }

        // 結果を反映
        if (bestTarget != null)
        {
            CurrentTarget = bestTarget;
            CurrentDirection = bestDirection;
            CurrentDistance = bestDistance;
        }
        else
        {
            ClearTarget();
        }
    }

    /// <summary>
    /// 視野角内に収まっているかを判定します。
    /// viewAngle が 360 の場合は常にtrueを返します。
    /// </summary>
    private bool IsWithinViewAngle(Vector3 directionToTarget)
    {
        if (viewAngle >= 360f) return true;

        Vector3 forward = transform.forward;
        directionToTarget.Normalize();

        // forward と directionToTarget のなす角を取得
        float angle = Vector3.Angle(forward, directionToTarget);
        return angle <= viewAngle * 0.5f;
    }

    /// <summary>
    /// ターゲットとの間に遮蔽物があるかをRaycastでチェックします。
    /// </summary>
    private bool IsBlockedByObstacle(Vector3 origin, Vector3 targetPosition, float distance)
    {
        Vector3 dir = (targetPosition - origin).normalized;

        // Raycastが当たったら遮蔽物ありと判定
        return Physics.Raycast(
            origin,
            dir,
            distance,
            obstacleLayerMask,
            QueryTriggerInteraction.Ignore
        );
    }

    /// <summary>
    /// 現在のターゲット情報をリセットします。
    /// </summary>
    private void ClearTarget()
    {
        CurrentTarget = null;
        CurrentDirection = Vector3.zero;
        CurrentDistance = Mathf.Infinity;
    }

    // ====== デバッグ描画 ======

    private void OnDrawGizmosSelected()
    {
        // シーンビューで探索範囲を可視化
        Gizmos.color = new Color(0f, 1f, 0f, 0.25f);
        Gizmos.DrawWireSphere(transform.position, searchRadius);

        // 視野角を簡易的に表示(360度の場合は省略)
        if (viewAngle < 360f)
        {
            Gizmos.color = Color.yellow;
            Vector3 forward = transform.forward;
            Quaternion leftRot = Quaternion.AngleAxis(-viewAngle * 0.5f, Vector3.up);
            Quaternion rightRot = Quaternion.AngleAxis(viewAngle * 0.5f, Vector3.up);

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

            Gizmos.DrawLine(transform.position, transform.position + leftDir * searchRadius);
            Gizmos.DrawLine(transform.position, transform.position + rightDir * searchRadius);
        }

        // 現在のターゲットへの線
        if (CurrentTarget != null)
        {
            Gizmos.color = Color.red;
            Gizmos.DrawLine(transform.position, CurrentTarget.position + targetOffset);
        }
    }
}

使い方の手順

  1. 敵オブジェクトにLayerを設定する
    敵プレハブ(例: Enemy)を選択し、インスペクター上部の「Layer」を任意のもの(例: Enemy)に設定します。
    まだLayerがない場合は「Add Layer…」から新規追加しておきましょう。
  2. AutoAimコンポーネントをアタッチする
    自動照準させたいオブジェクト(例: プレイヤー、タレット、敵の頭部など)に先ほどの AutoAim スクリプトをアタッチします。
    インスペクターで以下を設定します:
    • Target Layer Mask: 先ほど作成した Enemy Layer を指定
    • Search Radius: 例として 15〜20 くらい
    • View Angle: 360 なら全方向、120 なら前方だけなど
    • Obstacle Layer Mask: 壁や障害物のLayerを指定(なければ Default など)
    • Check Obstacles: 壁越しにロックオンさせたくない場合はオン
    • Search Interval: 0.05〜0.2 くらいにすると軽くて滑らかです
  3. 具体例①:プレイヤーの向きを自動で敵の方向に向ける
    プレイヤーの移動スクリプトとは別に「見た目の向きだけ」を制御する小さなスクリプトを作り、AutoAim を参照します。
    
    using UnityEngine;
    
    /// <summary>
    /// AutoAim の方向にプレイヤーの見た目だけをゆっくり回転させる例
    /// </summary>
    [RequireComponent(typeof(AutoAim))]
    public class AutoAimLookAt : MonoBehaviour
    {
        [SerializeField]
        [Tooltip("回転速度(度/秒)")]
        private float rotateSpeed = 360f;
    
        private AutoAim _autoAim;
    
        private void Awake()
        {
            _autoAim = GetComponent<AutoAim>();
        }
    
        private void Update()
        {
            // ターゲットがいなければ何もしない
            if (_autoAim.CurrentTarget == null)
            {
                return;
            }
    
            Vector3 dir = _autoAim.CurrentDirection;
            dir.y = 0f; // 水平方向だけ向かせたい場合はYを0に
    
            if (dir.sqrMagnitude <= 0f)
            {
                return;
            }
    
            Quaternion targetRotation = Quaternion.LookRotation(dir, Vector3.up);
            transform.rotation = Quaternion.RotateTowards(
                transform.rotation,
                targetRotation,
                rotateSpeed * Time.deltaTime
            );
        }
    }
    

    このように、「どの方向を向きたいか」は AutoAim が計算し、「実際に回転する処理」は AutoAimLookAt に分離できます。

  4. 具体例②:タレット(砲台)が自動で最も近い敵に向けて弾を撃つ
    タレットの土台に AutoAim を付け、砲身を回転させて弾を発射する簡単なスクリプトを追加します。
    
    using UnityEngine;
    
    /// <summary>
    /// AutoAim を使って最も近い敵を狙い、一定間隔で弾を撃つタレットの例
    /// </summary>
    [RequireComponent(typeof(AutoAim))]
    public class AutoAimTurretShooter : MonoBehaviour
    {
        [SerializeField]
        private Transform turretHead; // 回転させる砲身部分
    
        [SerializeField]
        private GameObject bulletPrefab;
    
        [SerializeField]
        private float fireInterval = 0.5f;
    
        [SerializeField]
        private float rotateSpeed = 360f;
    
        private AutoAim _autoAim;
        private float _timeSinceLastShot = 0f;
    
        private void Awake()
        {
            _autoAim = GetComponent<AutoAim>();
        }
    
        private void Update()
        {
            _timeSinceLastShot += Time.deltaTime;
    
            if (_autoAim.CurrentTarget == null)
            {
                return;
            }
    
            // 砲身をターゲット方向へ回転
            Vector3 dir = _autoAim.CurrentDirection;
            if (dir.sqrMagnitude > 0f)
            {
                Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up);
                turretHead.rotation = Quaternion.RotateTowards(
                    turretHead.rotation,
                    targetRot,
                    rotateSpeed * Time.deltaTime
                );
            }
    
            // 一定間隔で弾を発射
            if (_timeSinceLastShot >= fireInterval)
            {
                _timeSinceLastShot = 0f;
                Shoot(dir);
            }
        }
    
        private void Shoot(Vector3 direction)
        {
            if (bulletPrefab == null) return;
    
            GameObject bullet = Instantiate(
                bulletPrefab,
                turretHead.position,
                Quaternion.LookRotation(direction, Vector3.up)
            );
    
            // Bullet側でRigidbodyや移動処理を実装しておきましょう
        }
    }
    

    これでタレットのロジックも「敵の探索」「向きの制御」「弾の発射」に分割され、かなり見通しが良くなります。

メリットと応用

AutoAim コンポーネントを導入すると、次のようなメリットがあります。

  • プレハブの再利用性が高い
    プレイヤー用、タレット用、敵AI用など、異なるオブジェクトでも「自動照準」という共通機能をそのまま使い回せます。
    方向ベクトルとターゲット情報をプロパティとして公開しているだけなので、どんな移動・攻撃ロジックとも組み合わせやすいです。
  • レベルデザインが楽になる
    シーン上にタレットや敵をポンポン置いて AutoAim を付けるだけで、「近くに来たプレイヤーを狙う」ギミックがすぐ動きます。
    Search Radius を変えるだけで強さを調整できるので、難易度調整もしやすいです。
  • 責務が明確で保守しやすい
    「敵の探索と方向計算」だけに責務を絞っているため、バグが起きてもこのコンポーネント内だけを見ればOKです。
    移動やアニメーションの不具合とは切り離して考えられるのが大きな利点ですね。

応用としては、

  • 最も近い敵ではなく「HPが一番低い敵」を優先する
  • 一定時間ごとにターゲットをローテーションして狙いを変える
  • マルチロックオン(複数ターゲットをリストで保持)に拡張する

といった拡張も簡単に行えます。

最後に、「特定の距離以上に近づかれたらターゲットを外す」ような改造案の例を載せておきます。近接攻撃が苦手なタレットなどに使えます。


/// <summary>
/// 現在のターゲットが近づきすぎたらロックオンを解除する改造例
/// AutoAim の Update とは別スクリプトとして同じオブジェクトに付ける想定です。
/// </summary>
public class AutoAimMinDistanceCanceller : MonoBehaviour
{
    [SerializeField]
    [Tooltip("この距離より近づいたターゲットはロックオン解除します。")]
    private float minKeepDistance = 2f;

    private AutoAim _autoAim;

    private void Awake()
    {
        _autoAim = GetComponent<AutoAim>();
    }

    private void Update()
    {
        if (_autoAim.CurrentTarget == null)
        {
            return;
        }

        if (_autoAim.CurrentDistance <= minKeepDistance)
        {
            // かなり強引ですが、SearchIntervalを0にして即再探索させるなど、
            // プロパティをうまく使って挙動を調整できます。
            // ここでは単純にターゲットを無視する例として距離だけを見ています。
            Debug.Log("ターゲットが近すぎるためロックオン解除したい場合は、" +
                      "AutoAim側のロジックを少し拡張してみましょう。");
        }
    }
}

このように、AutoAim を「ターゲット情報提供コンポーネント」として小さく保っておくことで、周辺のスクリプトを自由に差し替えやすくなります。
巨大なGodクラスにせず、機能ごとにコンポーネントを分ける習慣をつけていきましょう。