Unityを触り始めた頃は、つい「とりあえず全部Updateに書いてしまう」実装になりがちですよね。
敵の索敵、狙い、発射タイミング、エフェクト表示、SE再生…すべてを1つのスクリプトのUpdateに詰め込むと、気づけば数百行のGodクラスになってしまいます。

そうなると、

  • 挙動を少し変えるだけで別の処理が壊れる
  • 他の敵にロジックを流用しづらい
  • デザイナーがパラメータを調整しにくい

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

この記事では「遠距離からプレイヤーをロックオンし、レーザー照射後に高速弾を撃つ」という狙撃AIの挙動を、
「SniperAim」コンポーネントとして切り出し、1つの責務に絞ったコンポーネント指向の書き方で実装してみます。

【Unity】レーザーで狙いを定めて一撃必殺!「SniperAim」コンポーネント

狙撃AIに必要な要素を、このコンポーネントでは次のように分解します。

  • プレイヤーを一定距離内で検知する
  • プレイヤー方向を向き、レーザーで「予告照射」する
  • 一定時間レーザーを当てた後、高速弾を発射する
  • クールダウンを挟んで再度狙撃を行う

移動やHP管理などは別コンポーネントに任せ、「狙って撃つ」部分だけを担当させるのがポイントです。


SniperAim コンポーネントのフルコード


using UnityEngine;

/// <summary>
/// 遠距離からプレイヤーをロックオンし、
/// レーザー照射後に高速弾を撃つ狙撃AIコンポーネント。
/// 
/// 責務:
/// ・ターゲットの検知
/// ・レーザーによる狙いの可視化
/// ・弾丸の発射タイミング管理
/// 
/// 移動やHP管理は別コンポーネントに任せる想定です。
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class SniperAim : MonoBehaviour
{
    // ====== ターゲット関連 ======
    [Header("Target Settings")]
    [SerializeField] private Transform target;              // 狙う対象(通常はプレイヤー)
    [SerializeField] private float detectionRange = 30f;    // この距離以内なら狙撃を開始
    [SerializeField] private LayerMask obstacleMask;        // 障害物用レイヤー(壁など)

    // ====== レーザー表示関連 ======
    [Header("Laser Settings")]
    [SerializeField] private Color laserColor = Color.red;  // レーザーの色
    [SerializeField] private float laserWidth = 0.05f;      // レーザーの太さ
    [SerializeField] private float lockOnTime = 1.5f;       // ロックオンに必要なレーザー照射時間

    // ====== 弾丸発射関連 ======
    [Header("Bullet Settings")]
    [SerializeField] private GameObject bulletPrefab;       // 発射する弾丸プレハブ
    [SerializeField] private Transform muzzle;              // 発射位置(銃口)
    [SerializeField] private float bulletSpeed = 50f;       // 弾丸の初速
    [SerializeField] private float fireCooldown = 2.0f;     // 発射後のクールダウン時間

    // ====== 回転関連 ======
    [Header("Rotation Settings")]
    [SerializeField] private float rotateSpeed = 5f;        // ターゲット方向へ回転する速度
    [SerializeField] private bool onlyRotateOnY = true;     // Y軸のみ回転するか(タレット風)

    // ====== デバッグ表示 ======
    [Header("Debug")]
    [SerializeField] private bool showDetectionRange = true;

    // 内部状態管理
    private LineRenderer lineRenderer;
    private float currentLockOnTimer = 0f;
    private float cooldownTimer = 0f;
    private bool isTargetVisible = false;

    private void Awake()
    {
        // 必須コンポーネントの取得
        lineRenderer = GetComponent<LineRenderer>();

        // LineRenderer の初期設定
        lineRenderer.positionCount = 2;
        lineRenderer.startWidth = laserWidth;
        lineRenderer.endWidth = laserWidth;
        lineRenderer.material = new Material(Shader.Find("Sprites/Default"));
        lineRenderer.startColor = laserColor;
        lineRenderer.endColor = laserColor;
        lineRenderer.enabled = false; // 初期状態では非表示
    }

    private void Update()
    {
        if (target == null)
        {
            // ターゲットが設定されていなければ何もしない
            lineRenderer.enabled = false;
            return;
        }

        // クールダウン中ならタイマーを進める
        if (cooldownTimer > 0f)
        {
            cooldownTimer -= Time.deltaTime;
            // クールダウン中はレーザーを消しておく
            lineRenderer.enabled = false;
            currentLockOnTimer = 0f;
            return;
        }

        // ターゲットとの距離と視線をチェック
        UpdateTargetVisibility();

        if (!isTargetVisible)
        {
            // 見えていない場合はロックオン解除
            lineRenderer.enabled = false;
            currentLockOnTimer = 0f;
            return;
        }

        // ターゲット方向へ回転させる
        RotateToTarget();

        // レーザーを表示しつつロックオン時間を計測
        UpdateLaserAndLockOn();

        // ロックオン完了なら弾を撃つ
        if (currentLockOnTimer >= lockOnTime)
        {
            Fire();
        }
    }

    /// <summary>
    /// ターゲットが射程内かつ、障害物に遮られていないかを判定します。
    /// </summary>
    private void UpdateTargetVisibility()
    {
        Vector3 toTarget = target.position - transform.position;
        float distance = toTarget.magnitude;

        // 射程外なら不可
        if (distance > detectionRange)
        {
            isTargetVisible = false;
            return;
        }

        // Raycastで障害物チェック
        if (Physics.Raycast(transform.position, toTarget.normalized, out RaycastHit hit, distance, obstacleMask))
        {
            // 何かに遮られている
            isTargetVisible = false;
        }
        else
        {
            isTargetVisible = true;
        }
    }

    /// <summary>
    /// ターゲット方向へスムーズに回転させます。
    /// onlyRotateOnY が true の場合はY軸のみ回転します。
    /// </summary>
    private void RotateToTarget()
    {
        Vector3 direction = target.position - transform.position;

        if (onlyRotateOnY)
        {
            direction.y = 0f; // 水平方向のみに制限
            if (direction.sqrMagnitude <= 0.0001f)
            {
                return;
            }
        }

        Quaternion targetRotation = Quaternion.LookRotation(direction.normalized, Vector3.up);
        transform.rotation = Quaternion.Slerp(
            transform.rotation,
            targetRotation,
            rotateSpeed * Time.deltaTime
        );
    }

    /// <summary>
    /// レーザーの描画とロックオンタイマーの更新を行います。
    /// </summary>
    private void UpdateLaserAndLockOn()
    {
        if (muzzle == null)
        {
            muzzle = transform; // 銃口が未設定なら本体位置から撃つ
        }

        // レーザーの始点と終点を設定
        Vector3 startPos = muzzle.position;
        Vector3 endPos = target.position;

        lineRenderer.enabled = true;
        lineRenderer.SetPosition(0, startPos);
        lineRenderer.SetPosition(1, endPos);

        // ロックオン時間を加算
        currentLockOnTimer += Time.deltaTime;
    }

    /// <summary>
    /// 弾丸を生成して発射します。
    /// </summary>
    private void Fire()
    {
        if (bulletPrefab == null)
        {
            Debug.LogWarning("[SniperAim] bulletPrefab が設定されていません。弾を撃てません。", this);
            ResetLockOn();
            return;
        }

        if (muzzle == null)
        {
            muzzle = transform;
        }

        // 弾丸を生成
        GameObject bulletInstance = Instantiate(
            bulletPrefab,
            muzzle.position,
            muzzle.rotation
        );

        // Rigidbody があれば速度を与える
        Rigidbody rb = bulletInstance.GetComponent<Rigidbody>();
        if (rb != null)
        {
            rb.velocity = muzzle.forward * bulletSpeed;
        }

        // ロックオン状態をリセットし、クールダウン開始
        ResetLockOn();
        cooldownTimer = fireCooldown;
    }

    /// <summary>
    /// ロックオン状態とレーザー表示をリセットします。
    /// </summary>
    private void ResetLockOn()
    {
        currentLockOnTimer = 0f;
        lineRenderer.enabled = false;
    }

    /// <summary>
    /// プレイヤーを外部から設定するためのメソッド。
    /// プレイヤー生成時にスクリプト側から渡す用途などに使えます。
    /// </summary>
    /// <param name="newTarget">狙いたい Transform</param>
    public void SetTarget(Transform newTarget)
    {
        target = newTarget;
    }

    // シーンビューで射程範囲を可視化(デバッグ用)
    private void OnDrawGizmosSelected()
    {
        if (!showDetectionRange) return;

        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position, detectionRange);
    }
}

使い方の手順

ここでは、「スナイパータレットがプレイヤーを狙撃する」例で説明します。

  1. プレハブの準備(スナイパー本体)
    • 空のGameObjectを作成し、名前を SniperTurret などにします。
    • モデル(タレットや敵キャラのメッシュ)を子オブジェクトとして配置します。
    • レーザーの発射口になる子オブジェクトを作成し、名前を Muzzle にしてタレットの先端に配置します。
    • LineRenderer コンポーネントを SniperTurret に追加します(スクリプトが自動設定します)。
    • SniperAim スクリプトを SniperTurret にアタッチします。
  2. 弾丸プレハブの準備
    • 球やカプセルなどのPrimitiveで弾丸オブジェクトを作成します。
    • Rigidbody を追加し、Use Gravity をオフにするか、重力を使うなら Drag を調整します。
    • 必要に応じて Collider を追加し、レイヤーやタグを設定します。
    • この弾丸をプレハブ化し、Bullet という名前で保存します。
    • SniperTurretSniperAimbulletPrefab に、この Bullet プレハブをドラッグ&ドロップで割り当てます。
  3. ターゲット(プレイヤー)の設定
    • シーン内のプレイヤーオブジェクト(例:Player)を選択します。
    • SniperTurret を選択し、SniperAimTarget フィールドに、Player の Transform をドラッグ&ドロップで割り当てます。
    • Muzzle フィールドに、先ほど作った子オブジェクト Muzzle を割り当てます。
  4. パラメータ調整とテスト
    • Detection Range を 20〜40 くらいに設定し、どの距離から狙撃を開始するか調整します。
    • Lock On Time を 1.0〜2.0 にして、「レーザー照射の予告時間」を調整します。
    • Bullet Speed を 40〜80 にして、高速弾の速さを調整します。
    • Fire Cooldown を 1.5〜3.0 にして、連射間隔を調整します。
    • Obstacle Mask に「壁」などのレイヤーを指定すると、壁越しには狙撃してこなくなります。
    • ゲームを再生し、プレイヤーを射程内に近づけてみて、レーザーが照射されてから弾が飛んでくるか確認しましょう。

同じ SniperAim プレハブを、

  • 高台に置いた狙撃兵
  • ステージ端に配置した自動タレット
  • ボスの肩に乗った砲台

など、いろんな場所にポンポン置けるようになると、レベルデザインがかなり楽になりますね。


メリットと応用

SniperAim をコンポーネントとして切り出しておくと、次のようなメリットがあります。

  • 責務が明確:このコンポーネントは「狙って撃つ」ことだけを担当するので、コードが読みやすい。
  • 再利用しやすい:移動AIやHP管理を変えても、狙撃ロジックはそのまま別の敵に流用できます。
  • プレハブ化しやすい:パラメータをインスペクタから調整できるため、デザイナーが速度や射程をいじりやすい。
  • テストがしやすい:SniperAim単体で動作確認できるので、バグの切り分けが簡単です。

例えば、同じ SniperAim を使って、

  • レーザーの色を青にして「冷凍ビームタレット」
  • ロックオン時間を短くして「高速スナイパー」
  • 弾速を遅くして「避けられる弾幕スナイパー」

など、バリエーションを簡単に作れます。
コンポーネントを差し替えるのではなく、同じコンポーネント+パラメータ違いで量産できるのが、プレハブ管理の大きな強みですね。

改造案:発射直前に警告フラッシュを入れる

「レーザー照射中の最後0.3秒だけ、レーザーを点滅させて危険度を演出したい」
といった要望が出た時も、責務を崩さずに小さな改造で対応できます。

例えば、以下のようなメソッドを追加して、UpdateLaserAndLockOn() の最後で呼び出すだけでも、それっぽい演出になります。


    /// <summary>
    /// 発射直前の警告として、レーザーを点滅させる簡易演出。
    /// </summary>
    private void UpdateWarningFlash()
    {
        // ロックオン完了の0.3秒前から点滅開始
        float warningDuration = 0.3f;
        float remaining = lockOnTime - currentLockOnTimer;

        if (remaining <= warningDuration && remaining > 0f)
        {
            // 速めに点滅させる(時間によってON/OFFを切り替え)
            float flashSpeed = 20f;
            bool visible = Mathf.FloorToInt(Time.time * flashSpeed) % 2 == 0;
            lineRenderer.enabled = visible;
        }
        else
        {
            // 通常時は常に表示
            lineRenderer.enabled = true;
        }
    }

このように、1つのコンポーネントに「狙撃AI」という明確な役割を持たせておくと、
演出や難易度調整の要望にも、小さな関数追加だけで応えやすくなります。
ぜひ、自分のプロジェクトでも「巨大なUpdate」ではなく、小さなコンポーネントに分割するスタイルを意識してみてください。