Unityの学習を始めたばかりだと、つい何でもかんでも Update() に書いてしまいがちですよね。
「入力のチェック」「弾の発射」「クールダウンの管理」「エフェクト再生」「サウンド再生」…全部1つのスクリプトに押し込んでしまうと、すぐに数百行のGodクラスが出来上がってしまいます。

そうなると、

  • ちょっとした仕様変更でも他の処理に影響が出やすい
  • プレイヤーと敵で微妙に違う弾発射ロジックを使い回しづらい
  • プレハブ化しづらく、レベルデザインのたびにコードをいじる羽目になる

といった問題が出てきます。

そこで今回は「弾を撃つ」という機能だけに責務を絞ったコンポーネント
ProjectileShooter(弾発射) を作って、

  • 入力で撃つプレイヤー
  • 一定間隔で撃つ敵タレット
  • 仕掛けとして定期的に弾を飛ばすトラップ

などに簡単に使い回せる形にしていきましょう。

【Unity】入力でも自動でも撃てる!「ProjectileShooter」コンポーネント

このコンポーネントは、

  • 親オブジェクトの位置・向きから弾プレハブをインスタンス化
  • 入力(ボタン)またはタイマー(自動射撃)で発射トリガー
  • 弾の初速や発射レートをインスペクターから調整可能

という役割だけに責務を限定しています。
弾そのものの挙動(移動・ダメージ処理など)は別コンポーネントに任せる前提で、「撃つこと」に集中させます。

フルコード:ProjectileShooter.cs


using UnityEngine;
using UnityEngine.InputSystem; // 新Input Systemを使う場合

/// <summary>弾を前方に発射するコンポーネント</summary>
public class ProjectileShooter : MonoBehaviour
{
    // --- 射撃モード設定 ---

    /// <summary>射撃のトリガー方法</summary>
    private enum FireMode
    {
        Input,     // 入力で撃つ
        Timer,     // 一定間隔で自動で撃つ
        Both       // 入力でもタイマーでも撃つ
    }

    [Header("射撃モード")]
    [SerializeField] private FireMode fireMode = FireMode.Input;

    // --- 弾プレハブと発射設定 ---

    [Header("弾プレハブ")]
    [Tooltip("発射する弾のプレハブ。Rigidbody 付き推奨")]
    [SerializeField] private GameObject projectilePrefab;

    [Header("発射位置・向き")]
    [Tooltip("弾を生成する位置。未指定ならこのオブジェクトのTransformを使用")]
    [SerializeField] private Transform muzzleTransform;

    [Tooltip("弾の初速(m/s)。Rigidbody が付いている場合は AddForce で前方へ加速")]
    [SerializeField] private float initialSpeed = 20f;

    [Tooltip("弾の生成時に追加するランダムな角度ブレ(度)。0 でブレなし")]
    [SerializeField] private float randomSpreadAngle = 0f;

    // --- 入力関連(Input System)---

    [Header("入力設定(FireMode が Input / Both のとき有効)")]
    [Tooltip("Input System の Action を使う場合に指定(例: PlayerInput から参照)")]
    [SerializeField] private InputActionReference fireActionReference;

    [Tooltip("旧 Input Manager を使う場合のボタン名(Input.GetButtonDown)。空なら無効")]
    [SerializeField] private string legacyFireButtonName = "Fire1";

    // --- タイマー射撃関連 ---

    [Header("タイマー射撃設定(FireMode が Timer / Both のとき有効)")]
    [Tooltip("自動射撃の間隔(秒)")]
    [SerializeField] private float fireInterval = 1.0f;

    [Tooltip("開始から何秒後に自動射撃を開始するか")]
    [SerializeField] private float autoFireStartDelay = 0f;

    // --- 共通クールダウン ---

    [Header("クールダウン")]
    [Tooltip("最小射撃間隔(秒)。0 なら連射制限なし")]
    [SerializeField] private float fireCooldown = 0.1f;

    [Tooltip("true の場合、クールダウン中は入力を無視する")]
    [SerializeField] private bool ignoreInputDuringCooldown = true;

    // --- サウンド / エフェクト(任意)---

    [Header("演出(任意)")]
    [Tooltip("発射時に再生するサウンド")]
    [SerializeField] private AudioSource fireAudioSource;

    [Tooltip("発射時に生成するマズルフラッシュなどのエフェクト")]
    [SerializeField] private GameObject muzzleEffectPrefab;

    [Tooltip("マズルエフェクトの寿命(秒)。0 以下なら Destroy しない")]
    [SerializeField] private float muzzleEffectLifetime = 1.0f;

    // --- 内部状態 ---

    private float _timeSinceLastShot = 0f;
    private float _autoFireTimer = 0f;
    private bool _autoFireEnabled = false;

    private void Awake()
    {
        // muzzleTransform が未設定なら自分自身を使う
        if (muzzleTransform == null)
        {
            muzzleTransform = transform;
        }
    }

    private void OnEnable()
    {
        // InputAction を有効化
        if (fireActionReference != null && fireActionReference.action != null)
        {
            fireActionReference.action.Enable();
        }

        // タイマー射撃の初期化
        _autoFireEnabled = (fireMode == FireMode.Timer || fireMode == FireMode.Both);
        _autoFireTimer = -autoFireStartDelay; // マイナスからスタートして、0 を超えたら発射開始
        _timeSinceLastShot = fireCooldown;    // 起動直後から撃てるようにしておく
    }

    private void OnDisable()
    {
        // InputAction を無効化
        if (fireActionReference != null && fireActionReference.action != null)
        {
            fireActionReference.action.Disable();
        }
    }

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

        HandleInputFire();
        HandleTimerFire(deltaTime);
    }

    /// <summary>
    /// 入力による射撃処理
    /// </summary>
    private void HandleInputFire()
    {
        if (fireMode != FireMode.Input && fireMode != FireMode.Both)
        {
            return;
        }

        bool firePressed = false;

        // 新 Input System
        if (fireActionReference != null && fireActionReference.action != null)
        {
            // ボタンが押された瞬間を検出
            if (fireActionReference.action.triggered)
            {
                firePressed = true;
            }
        }
        // 旧 Input Manager
        else if (!string.IsNullOrEmpty(legacyFireButtonName))
        {
            if (Input.GetButtonDown(legacyFireButtonName))
            {
                firePressed = true;
            }
        }

        if (!firePressed)
        {
            return;
        }

        // クールダウンチェック
        if (!CanFire())
        {
            if (ignoreInputDuringCooldown)
            {
                return;
            }
            // クールダウン中でも最後の入力を受け付けたい場合は、
            // ここでキューイング処理などを追加してもよい
        }

        Fire();
    }

    /// <summary>
    /// タイマーによる自動射撃処理
    /// </summary>
    private void HandleTimerFire(float deltaTime)
    {
        if (!_autoFireEnabled)
        {
            return;
        }

        _autoFireTimer += deltaTime;

        // autoFireStartDelay が経過するまで待つ
        if (_autoFireTimer < 0f)
        {
            return;
        }

        // fireInterval ごとに発射
        if (_autoFireTimer >= fireInterval)
        {
            // 間隔を保つために余剰時間を引く
            _autoFireTimer -= fireInterval;

            if (CanFire())
            {
                Fire();
            }
        }
    }

    /// <summary>
    /// 今撃てる状態かどうか(クールダウンのみ判定)
    /// </summary>
    private bool CanFire()
    {
        if (fireCooldown <= 0f)
        {
            return true;
        }

        return _timeSinceLastShot >= fireCooldown;
    }

    /// <summary>
    /// 実際に弾を生成して飛ばす処理
    /// </summary>
    private void Fire()
    {
        if (projectilePrefab == null)
        {
            Debug.LogWarning($"[ProjectileShooter] projectilePrefab が設定されていません: {name}");
            return;
        }

        _timeSinceLastShot = 0f;

        // 発射位置と向き
        Vector3 spawnPosition = muzzleTransform.position;
        Quaternion spawnRotation = muzzleTransform.rotation;

        // ランダムなブレを追加(必要な場合)
        if (randomSpreadAngle > 0f)
        {
            float yaw = Random.Range(-randomSpreadAngle, randomSpreadAngle);
            float pitch = Random.Range(-randomSpreadAngle, randomSpreadAngle);
            Quaternion spread = Quaternion.Euler(pitch, yaw, 0f);
            spawnRotation *= spread;
        }

        // 弾プレハブをインスタンス化
        GameObject projectileInstance = Instantiate(projectilePrefab, spawnPosition, spawnRotation);

        // Rigidbody が付いていれば前方へ力を加える
        Rigidbody rb = projectileInstance.GetComponent<Rigidbody>();
        if (rb != null && initialSpeed > 0f)
        {
            rb.velocity = Vector3.zero; // 念のため初期化
            rb.angularVelocity = Vector3.zero;

            // forward 方向に速度を与える
            rb.AddForce(muzzleTransform.forward * initialSpeed, ForceMode.VelocityChange);
        }

        // 演出:サウンド再生
        if (fireAudioSource != null)
        {
            fireAudioSource.Play();
        }

        // 演出:マズルエフェクト
        if (muzzleEffectPrefab != null)
        {
            GameObject effect = Instantiate(muzzleEffectPrefab, spawnPosition, spawnRotation);

            if (muzzleEffectLifetime > 0f)
            {
                Destroy(effect, muzzleEffectLifetime);
            }
        }
    }

    // --- 外部から明示的に撃たせたい場合用の公開メソッド ---

    /// <summary>
    /// スクリプトから明示的に発射させたいときに使用(クールダウンは適用される)
    /// </summary>
    public void TryFire()
    {
        if (CanFire())
        {
            Fire();
        }
    }

    /// <summary>
    /// クールダウンを無視して強制的に発射させる(特殊演出用)
    /// </summary>
    public void ForceFire()
    {
        Fire();
    }
}

使い方の手順

ここでは代表的な3パターンを例に、使い方を手順で見ていきます。

① プレイヤーがクリック/ボタンで弾を撃つ

  1. シーン内のプレイヤーオブジェクト(例: Player)を選択。
  2. ProjectileShooter コンポーネントを追加。
  3. 弾プレハブを用意して projectilePrefab に割り当てます。
    例:
    • 3Dの場合: 球体に Rigidbody を付けて「Bullet」プレハブ化
    • 2Dの場合: スプライトに Rigidbody2D を付けたプレハブを使い、
      このコンポーネント側の Rigidbody 取得部分を Rigidbody2D 用に改造してもOK
  4. fireModeInput に設定。
    • 新 Input System を使っている場合:
      • InputActionAsset から「Shoot」「Fire」などのアクションを作成
      • PlayerInput コンポーネント経由で InputActionReference を作り、
        それを fireActionReference にドラッグ&ドロップ
    • 旧 Input Manager を使う場合:
      • legacyFireButtonNameFire1 などのボタン名を入力
  5. 発射位置を銃口にしたい場合、プレイヤーの子オブジェクトに「Muzzle」などの空オブジェクトを作り、
    それを muzzleTransform に割り当てます。
  6. initialSpeedfireCooldown を好みに合わせて調整。
    再生してボタン(またはクリック)で弾が飛べばOKです。

② 敵タレットが一定間隔で自動射撃する

  1. シーンにタレット用のオブジェクトを配置(例: EnemyTurret)。
  2. ProjectileShooter コンポーネントを追加。
  3. タレットが撃ちたい弾プレハブを projectilePrefab に設定。
  4. fireModeTimer に設定。
  5. fireInterval を 0.5〜2.0 秒あたりに設定して連射ペースを調整。
    例えば 1.5 にすると、1.5秒ごとに弾を1発撃ちます。
  6. ゲーム開始からしばらくしてから撃たせたい場合は autoFireStartDelay に数秒入れておきましょう。
  7. タレットの砲身の先端に「Muzzle」オブジェクトを置き、muzzleTransform に設定しておくと、
    見た目通りの位置から弾が出ます。

③ 仕掛けトラップ:壁から定期的に弾が飛び出す

  1. 壁やトラップ用のオブジェクト(例: SpikeShooter)をシーンに配置。
  2. ProjectileShooter コンポーネントを追加。
  3. トゲ弾や炎の玉などのプレハブを projectilePrefab に設定。
  4. fireModeTimerBoth に設定。
    • Timer: 常に一定間隔で飛び出すトラップ
    • Both: タイマーに加えて、スイッチなどからスクリプトで TryFire() を呼び出すことも可能
  5. トラップの向き(transform.forward)を調整して、弾が正しい方向に飛ぶようにします。
    必要に応じて randomSpreadAngle を少しだけ(例: 5〜10度)付けると、
    ばらけた感じが出て自然になります。

メリットと応用

ProjectileShooter を「弾を撃つだけ」のコンポーネントとして分離しておくと、

  • プレハブ化しやすい:プレイヤー・敵・ギミックなど、どこにでも貼るだけで弾発射機能を付与できる
  • レベルデザインが楽:シーン上で fireIntervalautoFireStartDelay を調整するだけで
    難易度やリズムを調整できる
  • 責務が明確:入力処理・AI・移動・ダメージ処理などと分離されているので、
    バグの切り分けや仕様変更がしやすい
  • 拡張しやすい:弾の種類を変えたいときはプレハブを差し替えるだけで済む

例えば、敵AIコンポーネントは「いつ撃つか」だけを決めて、実際の発射は ProjectileShooterTryFire() を呼ぶだけ、という構成にしておくと、
AIのロジックと発射ロジックがきれいに分離できます。

改造案:弾の発射方向をターゲットに向ける

標準の実装では muzzleTransform.forward 方向に弾を飛ばしていますが、
敵がプレイヤーを追いかけるようなタレットにしたい場合、以下のような関数を追加してターゲット方向に撃てるようにしても便利です。


    /// <summary>
    /// 指定したターゲットに向かって発射する(クールダウンあり)
    /// </summary>
    public void TryFireAtTarget(Transform target)
    {
        if (target == null)
        {
            return;
        }

        if (!CanFire())
        {
            return;
        }

        // 一時的に muzzleTransform をターゲット方向に向ける
        Vector3 originalForward = muzzleTransform.forward;
        Vector3 direction = (target.position - muzzleTransform.position).normalized;
        muzzleTransform.forward = direction;

        Fire();

        // 向きを元に戻す
        muzzleTransform.forward = originalForward;
    }

このように、小さな責務ごとにコンポーネントを分けておくと、
「撃ち方を変える」「狙い方を変える」といった改造も、シンプルな関数追加だけで済ませやすくなります。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。