Unityを触り始めた頃、つい何でもかんでも Update() に書いてしまいがちですよね。
プレイヤーの移動、カメラ追従、敵AI、UI更新…全部1つのスクリプトに押し込んでしまうと、だんだん「どこを触ればいいのか分からない巨大クラス」が出来上がってしまいます。

特に「敵AI」や「砲台の制御」は複雑になりやすく、
・ターゲット探索ロジック
・回転制御ロジック
・発射ロジック
などがごちゃ混ぜになりがちです。

そこでこの記事では、「固定砲台として敵を追尾し続ける」だけに責務を絞ったコンポーネント
TurretAI を作っていきます。
移動は一切せず、指定した範囲内に入った敵を検知し、常に正確に銃口を向け続けるコンポーネントです。

【Unity】敵を自動追尾する固定砲台!「TurretAI」コンポーネント

以下が、TurretAI コンポーネントのフルコードです。
・「どのレイヤーを敵として扱うか」
・「どの向きが砲口なのか」
・「どのくらいの速度で回転するか」
などをインスペクターから調整できるようにしてあります。


using UnityEngine;

/// <summary>
// 固定砲台用のシンプルなAIコンポーネント。
// ・移動は一切行わない
// ・指定した半径内にいる「敵レイヤー」のオブジェクトを探索
// ・最も近い敵に対して砲身を向け続ける
// ・2D/3Dどちらでも使えるよう、回転軸を選択可能
// 
// 責務は「敵の検知」と「向きの制御」だけに限定し、
// 発射処理は別コンポーネントに分離する想定です。
// (Single Responsibility を意識)
/// </summary>
public class TurretAI : MonoBehaviour
{
    // --- 設定項目(インスペクターで調整する) ---

    [Header("ターゲット検知設定")]
    [SerializeField]
    [Tooltip("敵として判定するレイヤー。Enemyなどを指定。")]
    private LayerMask enemyLayer;

    [SerializeField]
    [Tooltip("敵を検知する半径。")]
    private float detectionRadius = 10f;

    [SerializeField]
    [Tooltip("視野角(度)。0〜180。0なら視野制限なし。例: 90なら正面±45度のみ検知。")]
    private float viewAngle = 0f;

    [SerializeField]
    [Tooltip("砲台の前方向。3Dなら通常はZ+、2DのTopDownならX+など、モデルに合わせて変更。")]
    private Vector3 localForward = Vector3.forward;

    [Header("回転設定")]
    [SerializeField]
    [Tooltip("1秒あたりの回転速度(度)。大きいほど素早く敵を追尾。")]
    private float rotationSpeed = 360f;

    [SerializeField]
    [Tooltip("Y軸だけを回転させるか? 3Dのタレットで水平回転だけ行いたい場合にON。")]
    private bool rotateOnlyY = true;

    [SerializeField]
    [Tooltip("ターゲットとの角度がこの値(度)以下なら「ロックオン済み」とみなす。")]
    private float lockOnAngleThreshold = 2f;

    [Header("デバッグ表示")]
    [SerializeField]
    [Tooltip("シーンビューに検知範囲などをギズモ表示するか?")]
    private bool drawGizmos = true;

    [SerializeField]
    [Tooltip("現在ロックしているターゲットをシーンビューに表示するか?")]
    private bool showCurrentTarget = true;

    // --- ランタイム状態 ---

    /// <summary>
    /// 現在追尾中のターゲットTransform(nullの場合は未検知)
    /// </summary>
    public Transform CurrentTarget => currentTarget;
    private Transform currentTarget;

    // 使い回し用バッファ
    private readonly Collider[] overlapResults = new Collider[16];

    private void Update()
    {
        // 1. ターゲット探索
        UpdateTarget();

        // 2. 砲台の向きを更新
        RotateToTarget();
    }

    /// <summary>
    /// 検知範囲内の敵から、最も近いものをターゲットとして選ぶ
    /// </summary>
    private void UpdateTarget()
    {
        int hitCount = Physics.OverlapSphereNonAlloc(
            transform.position,
            detectionRadius,
            overlapResults,
            enemyLayer,
            QueryTriggerInteraction.Ignore
        );

        Transform nearest = null;
        float nearestSqrDist = float.MaxValue;

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

            Transform t = col.attachedRigidbody ? col.attachedRigidbody.transform : col.transform;

            // 自分自身は除外
            if (t == transform) continue;

            Vector3 toTarget = t.position - transform.position;
            float sqrDist = toTarget.sqrMagnitude;

            // 視野角が設定されている場合はチェック
            if (viewAngle > 0f)
            {
                Vector3 worldForward = transform.TransformDirection(localForward).normalized;
                Vector3 dir = toTarget.normalized;
                float angle = Vector3.Angle(worldForward, dir);
                if (angle > viewAngle * 0.5f)
                {
                    // 視野外
                    continue;
                }
            }

            if (sqrDist < nearestSqrDist)
            {
                nearestSqrDist = sqrDist;
                nearest = t;
            }
        }

        currentTarget = nearest;
    }

    /// <summary>
    /// 現在のターゲットに向かって、砲台の回転を補間する
    /// </summary>
    private void RotateToTarget()
    {
        if (currentTarget == null)
        {
            // ターゲットがいないときは何もしない
            return;
        }

        Vector3 toTarget = currentTarget.position - transform.position;

        // 距離がほぼゼロなら回転不要
        if (toTarget.sqrMagnitude < 0.0001f)
        {
            return;
        }

        // 回転軸の制御
        if (rotateOnlyY)
        {
            // 水平面上に投影してY軸回転だけにする(3Dタレット向け)
            toTarget.y = 0f;
            if (toTarget.sqrMagnitude < 0.0001f)
            {
                return;
            }
        }

        // 「localForward」が現在どちらを向いているかをワールド空間で計算
        Vector3 currentForward = transform.TransformDirection(localForward).normalized;
        Vector3 desiredForward = toTarget.normalized;

        // 目標回転を計算
        Quaternion currentRotation = transform.rotation;
        Quaternion desiredRotation = Quaternion.FromToRotation(currentForward, desiredForward) * currentRotation;

        if (rotateOnlyY)
        {
            // Y軸だけ回転させるため、X/Zは固定する
            Vector3 euler = desiredRotation.eulerAngles;
            desiredRotation = Quaternion.Euler(0f, euler.y, 0f);
        }

        // スムーズに補間して回転
        float maxDegreesDelta = rotationSpeed * Time.deltaTime;
        transform.rotation = Quaternion.RotateTowards(
            currentRotation,
            desiredRotation,
            maxDegreesDelta
        );
    }

    /// <summary>
    /// 現在ターゲットがロックオンされているかどうか
    /// (砲口の向きとターゲット方向の角度差で判定)
    /// </summary>
    public bool IsLockedOn()
    {
        if (currentTarget == null) return false;

        Vector3 toTarget = currentTarget.position - transform.position;
        if (rotateOnlyY)
        {
            toTarget.y = 0f;
        }

        if (toTarget.sqrMagnitude < 0.0001f) return false;

        Vector3 worldForward = transform.TransformDirection(localForward).normalized;
        Vector3 dir = toTarget.normalized;
        float angle = Vector3.Angle(worldForward, dir);
        return angle <= lockOnAngleThreshold;
    }

    // --- シーンビュー用の可視化 ---

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

        // 検知半径
        Gizmos.color = new Color(0f, 1f, 0f, 0.25f);
        Gizmos.DrawWireSphere(transform.position, detectionRadius);

        // 前方向
        Vector3 worldForward = transform.TransformDirection(localForward).normalized;
        Gizmos.color = Color.cyan;
        Gizmos.DrawLine(transform.position, transform.position + worldForward * detectionRadius);

        // 視野角
        if (viewAngle > 0f)
        {
            Gizmos.color = new Color(1f, 1f, 0f, 0.6f);
            DrawViewAngleGizmo(worldForward);
        }

        // 現在ターゲット
        if (showCurrentTarget && currentTarget != null)
        {
            Gizmos.color = Color.red;
            Gizmos.DrawLine(transform.position, currentTarget.position);
            Gizmos.DrawSphere(currentTarget.position, 0.2f);
        }
    }

    /// <summary>
    /// 視野角を扇形で描画(シーンビューの補助用)
    /// </summary>
    private void DrawViewAngleGizmo(Vector3 worldForward)
    {
        // 扇形の端の方向ベクトルを計算
        float halfAngle = viewAngle * 0.5f;
        Quaternion rotLeft = Quaternion.AngleAxis(-halfAngle, Vector3.up);
        Quaternion rotRight = Quaternion.AngleAxis(halfAngle, Vector3.up);

        Vector3 leftDir = rotLeft * worldForward;
        Vector3 rightDir = rotRight * worldForward;

        Vector3 origin = transform.position;
        Gizmos.DrawLine(origin, origin + leftDir * detectionRadius);
        Gizmos.DrawLine(origin, origin + rightDir * detectionRadius);
    }
}

使い方の手順

ここからは、実際に「固定砲台オブジェクト」に TurretAI を組み込む手順を見ていきましょう。

手順① 固定砲台用のプレハブ(またはGameObject)を用意する

  • シーン上に空の GameObject を作成し、名前を Turret などにします。
  • モデルを使う場合は、砲台のメッシュ(FBXなど)を子オブジェクトとして配置してOKです。
  • 「どの向きが砲口か」を決めておきましょう(例:Z+方向が砲口)。

手順② TurretAI コンポーネントをアタッチする

  • Turret オブジェクトを選択し、TurretAI.cs をアタッチします。
  • インスペクターで以下を設定します。
    • Enemy Layer:敵キャラクターに設定しているレイヤー(例:Enemy)を指定。
    • Detection Radius:砲台が敵を検知する距離。例えば 15〜25 くらい。
    • View Angle
      • 0:全方向を検知(全周囲レーダー的な挙動)。
      • 90:前方 ±45度のみ検知(正面だけ狙うタレット)。
    • Local Forward:砲口の向きが Z+ なら (0,0,1)、X+ なら (1,0,0) など。
    • Rotate Only Y:3Dゲームで水平回転だけしたい場合は ON、2Dトップダウンで上下にも向けたい場合は OFF。

手順③ 敵キャラクターにレイヤーを設定する

  • 敵キャラクターのプレハブ(例:Enemy)を選択します。
  • インスペクター上部の「Layer」から、Enemy レイヤーを作成して割り当てます。
  • コライダー(BoxCollider / CapsuleCollider など)を必ず付けてください。
    TurretAIPhysics.OverlapSphereNonAlloc でコライダーを検知します。

手順④ 発射処理は別コンポーネントで追加する(例:Player用・敵用・動く床用)

TurretAI は「敵を見つけて向きを合わせる」だけに責務を絞っているので、
弾を撃つ処理は別スクリプトとしてアタッチするのがおすすめです。

例えば、以下のようなシンプルな「弾を撃つだけ」コンポーネントを別に用意して、
プレイヤー基地の防衛タレットや、敵の固定砲台に共通で使い回すことができます。


using UnityEngine;

/// <summary>
// TurretAI と組み合わせて使う簡易射撃コンポーネント。
// ・TurretAI がロックオンしているときだけ一定間隔で弾を生成する。
// ・弾プレハブは Rigidbody を持ち、前方向に力を加えて飛ばす想定。
/// </summary>
[RequireComponent(typeof(TurretAI))]
public class TurretShooter : MonoBehaviour
{
    [SerializeField]
    private GameObject bulletPrefab;

    [SerializeField]
    private Transform muzzleTransform; // 砲口位置

    [SerializeField]
    private float shootInterval = 0.5f;

    [SerializeField]
    private float bulletSpeed = 20f;

    private TurretAI turretAI;
    private float timer;

    private void Awake()
    {
        turretAI = GetComponent<TurretAI>();
    }

    private void Update()
    {
        timer += Time.deltaTime;

        // ターゲットがロックオンされているときだけ撃つ
        if (turretAI.IsLockedOn() && timer >= shootInterval)
        {
            timer = 0f;
            Shoot();
        }
    }

    private void Shoot()
    {
        if (bulletPrefab == null || muzzleTransform == null) return;

        GameObject bullet = Instantiate(
            bulletPrefab,
            muzzleTransform.position,
            muzzleTransform.rotation
        );

        // 弾に Rigidbody があれば前方向に力を加えて飛ばす
        Rigidbody rb = bullet.GetComponent<Rigidbody>();
        if (rb != null)
        {
            rb.velocity = muzzleTransform.forward * bulletSpeed;
        }
    }
}

このように、「向きを制御するTurretAI」「弾を撃つTurretShooter」 を分離しておくことで、
・プレイヤー基地用タレット
・敵の固定砲台
・動く床の上に乗っているタレット(動くのは床だけで、タレット自身はTurretAIで回転)
など、さまざまなバリエーションに簡単に流用できます。

メリットと応用

TurretAI を使うメリットは、主に次のような点です。

  • 責務が明確で小さい
    TurretAI は「敵の検知と回転制御」だけに責務を絞っているので、
    発射処理やダメージ処理は別コンポーネントに分けやすく、巨大なGodクラスを避けられます。
  • プレハブ化しやすい
    TurretAI + 見た目のモデル + (必要なら)TurretShooter をまとめてプレハブ化すれば、
    レベルデザイナーはシーン上にドラッグ&ドロップするだけで「動く固定砲台」を量産できます。
  • レベルデザインが楽になる
    検知半径や視野角、回転速度をインスペクターから調整できるので、
    「この部屋の砲台はすぐ気づくけど、あの通路の砲台は鈍感にしたい」といった微調整も簡単です。
  • 2D/3Dのどちらでも使える
    localForwardrotateOnlyY を切り替えるだけで、
    3DのFPSタレットから2Dトップダウンの砲台まで幅広く対応できます。

応用として、例えば「プレイヤーがステルス状態のときは検知しない」なども、
TurretAI を改造するか、外側から制御するだけで実現できます。

以下は、簡易的な「手動ターゲット指定」機能 を追加する改造案の一例です。
外部のスクリプトから「このTransformを狙ってくれ」と指示できるようになります。


    /// <summary>
    /// 外部からターゲットを強制的に指定するためのAPI例。
    /// null を渡すと、通常の自動検知に戻る。
    /// </summary>
    public void ForceSetTarget(Transform target)
    {
        currentTarget = target;
    }

例えば、ボス戦の演出で「一定時間だけプレイヤーではなく特定のギミックを狙わせる」など、
演出スクリプトから ForceSetTarget() を呼ぶことで柔軟な制御ができます。

このように、小さなコンポーネントを組み合わせるスタイルに慣れておくと、
プロジェクトが大きくなってもスクリプトが破綻しにくくなります。
TurretAI をベースに、ぜひ自分のゲームに合わせたタレット挙動を育てていきましょう。