Unityを触り始めた頃は、つい「とりあえず Update に全部書くか…」となりがちですよね。
移動処理、入力処理、弾の発射、UIの更新…すべてを1つのスクリプトの Update に突っ込んでしまうと、次のような問題が起きます。

  • 機能追加のたびに1ファイルがどんどん巨大化して、スクロール地獄になる
  • 「どこがバグってるのか」が追いにくく、デバッグがつらい
  • 別のキャラやオブジェクトで同じ処理を再利用しづらい

そこで今回は、「溜め撃ち」の機能だけに責務を絞ったコンポーネント
「ChargeShot」 を作ってみましょう。
ボタンの長押し時間を計測し、ボタンを離した瞬間に、その時間に応じて
威力やサイズが変化する弾 を発射する仕組みです。

プレイヤーキャラにアタッチするだけで「溜め撃ち」が生えるような、小さくて再利用しやすいコンポーネントを目指します。

【Unity】長押しで威力アップする溜め撃ち!「ChargeShot」コンポーネント

フルコード(ChargeShot.cs)


using UnityEngine;
using UnityEngine.InputSystem; // 新Input System用

/// <summary>溜め撃ちの弾の挙動(移動とダメージ値保持)</summary>
[RequireComponent(typeof(Rigidbody))]
public class ChargeBullet : MonoBehaviour
{
    [SerializeField] private float speed = 10f;     // 弾の移動速度
    [SerializeField] private float lifeTime = 5f;   // 何秒後に自動で消えるか

    private Rigidbody _rb;

    /// <summary>外部から参照したい攻撃力(ChargeShot側で設定)</summary>
    public float Power { get; private set; }

    private void Awake()
    {
        _rb = GetComponent<Rigidbody>();
    }

    /// <summary>
    /// 弾の初期設定を行う(方向と威力を渡す)
    /// </summary>
    public void Initialize(Vector3 direction, float power)
    {
        Power = power;

        // 指定方向へ速度を与える
        _rb.velocity = direction.normalized * speed;

        // 一定時間後に自動破棄
        Destroy(gameObject, lifeTime);
    }

    // ここでは当たり判定の実装は省略。
    // 実際のゲームでは OnCollisionEnter などで敵にダメージを与える処理を追加しましょう。
}

/// <summary>
/// 溜め撃ちを管理するコンポーネント。
/// 入力の長押し時間を計測し、ボタンを離したときに溜め時間に応じた弾を発射する。
/// </summary>
public class ChargeShot : MonoBehaviour
{
    [Header("Input 設定")]
    [SerializeField]
    private string fireActionName = "Fire"; 
    // Input Actions の Action 名(PlayerInput の Action Map 内の名前)

    [Header("チャージ設定")]
    [SerializeField] private float minChargeTime = 0.1f;   // この時間未満は「ショット無し」にしたい場合
    [SerializeField] private float maxChargeTime = 2.0f;   // これ以上はチャージ時間を伸ばさない
    [SerializeField] private float minPower = 10f;         // 最小威力
    [SerializeField] private float maxPower = 50f;         // 最大威力
    [SerializeField] private float minScale = 0.5f;        // 弾の最小スケール
    [SerializeField] private float maxScale = 2.0f;        // 弾の最大スケール

    [Header("発射設定")]
    [SerializeField] private ChargeBullet bulletPrefab;    // 発射する弾のプレハブ
    [SerializeField] private Transform muzzleTransform;    // 発射位置(銃口など)
    [SerializeField] private Vector3 shotDirection = Vector3.forward; // 発射方向(ローカル空間)

    [Header("デバッグ表示")]
    [SerializeField] private bool showDebugLog = false;

    private PlayerInput _playerInput;
    private bool _isCharging = false;
    private float _chargeStartTime = 0f;

    private void Awake()
    {
        // PlayerInput は同じオブジェクトに付けておく想定
        _playerInput = GetComponent<PlayerInput>();
        if (_playerInput == null)
        {
            Debug.LogWarning(
                "ChargeShot: 同じ GameObject に PlayerInput が見つかりません。" +
                "新Input Systemを使う場合は PlayerInput を追加し、Action 名を設定してください。"
            );
        }
    }

    private void OnEnable()
    {
        // PlayerInput がある場合はイベントに登録(Action 名で検索)
        if (_playerInput != null && !string.IsNullOrEmpty(fireActionName))
        {
            var action = _playerInput.actions[fireActionName];
            if (action != null)
            {
                action.started  += OnFireStarted;
                action.canceled += OnFireCanceled;
            }
        }
    }

    private void OnDisable()
    {
        if (_playerInput != null && !string.IsNullOrEmpty(fireActionName))
        {
            var action = _playerInput.actions[fireActionName];
            if (action != null)
            {
                action.started  -= OnFireStarted;
                action.canceled -= OnFireCanceled;
            }
        }
    }

    /// <summary>
    /// 発射ボタンが押され始めた時に呼ばれる
    /// </summary>
    private void OnFireStarted(InputAction.CallbackContext context)
    {
        _isCharging = true;
        _chargeStartTime = Time.time;

        if (showDebugLog)
        {
            Debug.Log("ChargeShot: チャージ開始");
        }
    }

    /// <summary>
    /// 発射ボタンが離された時に呼ばれる
    /// </summary>
    private void OnFireCanceled(InputAction.CallbackContext context)
    {
        if (!_isCharging)
        {
            return;
        }

        _isCharging = false;

        float chargeDuration = Time.time - _chargeStartTime;

        // 最小チャージ時間未満ならキャンセル(「暴発防止」用)
        if (chargeDuration < minChargeTime)
        {
            if (showDebugLog)
            {
                Debug.Log($"ChargeShot: チャージ時間が短すぎたため発射しません ({chargeDuration:F2}秒)");
            }
            return;
        }

        // 0.0 ~ 1.0 に正規化したチャージ率
        float t = Mathf.Clamp01(chargeDuration / maxChargeTime);

        // チャージ率に応じて威力とスケールを補間
        float power = Mathf.Lerp(minPower, maxPower, t);
        float scale = Mathf.Lerp(minScale, maxScale, t);

        FireChargedShot(power, scale);

        if (showDebugLog)
        {
            Debug.Log($"ChargeShot: 発射! チャージ時間={chargeDuration:F2}秒, 威力={power:F1}, スケール={scale:F2}");
        }
    }

    /// <summary>
    /// 実際に弾プレハブを生成して、威力とサイズを設定して発射する
    /// </summary>
    private void FireChargedShot(float power, float scale)
    {
        if (bulletPrefab == null)
        {
            Debug.LogWarning("ChargeShot: bulletPrefab が設定されていません。");
            return;
        }

        // 発射位置の決定(指定がなければ自分の位置)
        Transform spawnPoint = muzzleTransform != null ? muzzleTransform : transform;

        // 弾を生成
        ChargeBullet bulletInstance = Instantiate(
            bulletPrefab,
            spawnPoint.position,
            spawnPoint.rotation
        );

        // スケールを設定
        bulletInstance.transform.localScale = Vector3.one * scale;

        // 発射方向(ローカル→ワールド変換)
        Vector3 worldDirection = spawnPoint.TransformDirection(shotDirection.normalized);

        // 弾に初期設定を渡す
        bulletInstance.Initialize(worldDirection, power);
    }

    // もし旧Input Manager を使いたい場合は、Update で Input.GetButton 系を読む別メソッドを用意してもOK。
    // その場合も「チャージの開始/終了」をこのクラス内に閉じ込めると、責務がはっきりして保守しやすくなります。
}

使い方の手順

  1. 弾プレハブ(ChargeBullet)を作る
    • 空の GameObject を作成し、名前を ChargeBullet にする。
    • Rigidbody コンポーネントを追加し、Use Gravity をオフ(弾をまっすぐ飛ばしたい場合)。
    • SphereCylinder などの Mesh を子オブジェクトとして付けて、見た目を作る。
    • 上記コードの ChargeBullet コンポーネントを追加する。
    • この GameObject を Project ビューにドラッグしてプレハブ化する。
  2. プレイヤーオブジェクトに ChargeShot を付ける
    例として、3Dアクションのプレイヤーキャラに溜め撃ちを付けたい場合:
    • プレイヤーの GameObject(例: Player)を選択。
    • PlayerInput コンポーネントを追加し、Input Actions アセットを設定。
    • Input Actions 内に「Fire」などの Action を作成し、Button タイプでマウス左ボタンやゲームパッドのボタンにバインドする。
    • 同じオブジェクトに ChargeShot コンポーネントを追加。
    • Bullet Prefab に先ほど作った ChargeBullet プレハブをドラッグ&ドロップ。
    • 銃口になる子オブジェクト(例: Muzzle)を作り、その Transform を Muzzle Transform に指定。
    • 発射方向 Shot Direction はデフォルトの (0,0,1)(Z+)のままでOK(Muzzle の forward 方向に飛びます)。
  3. 敵や動く床にも簡単に応用できる
    • 敵のチャージ攻撃:敵キャラに ChargeShot を付けて、AIスクリプトから
      「一定時間ごとに OnFireStarted → OnFireCanceled を模倣」することで、ため攻撃を実装できます。
      プレイヤー入力ではなく、スクリプト制御でチャージ時間を決める形ですね。
    • 動く砲台(タレット):動く床や壁に砲台モデルを載せ、その砲台に ChargeShot を付ければ、
      ステージギミックとして定期的に溜め撃ちを撃つトラップも作れます。
  4. パラメータを調整してゲーム性を作る
    • Max Charge Time を短め(例: 1秒)にするとテンポの良いアクションに。
    • Min Charge Time を0に近づけると、タップでも弱弾が出て、長押しで強弾になる操作感に。
    • Min/Max PowerMin/Max Scale をいじって、見た目とダメージのバランスを取っていきましょう。

メリットと応用

ChargeShot コンポーネントを分離しておくことで、次のようなメリットがあります。

  • プレハブ化しやすい:プレイヤー用、敵用、ボス用など、同じ溜め撃ちロジックを持つオブジェクトを
    プレハブとして量産できます。調整したいのはパラメータだけなので、コードをコピペする必要がありません。
  • レベルデザインが楽:ステージ中のギミック砲台やトラップに、そのまま ChargeShot を付けて
    弾プレハブと向きを変えるだけで「溜めて撃つギミック」が作れます。レベルデザイナーは Inspector で数値を調整するだけ。
  • 責務が明確:入力の長押し時間を計測し、チャージ量に応じて弾を生成する、という責務だけに絞っているので、
    移動やHP管理などと混ざらず、コードの見通しが良くなります。

さらに、溜め中の演出(エフェクトやサウンド)を追加すると、気持ちよさが一気に上がります。
例えば、チャージ中はプレイヤーの手元を光らせたり、チャージ量に応じてピッチが変わるSEを鳴らしたり、といった拡張が考えられます。

最後に、改造案として「現在のチャージ率を返す」ヘルパー関数を追加する例を載せておきます。
これを使えば、UIゲージやエフェクト側からチャージ量を簡単に参照できます。


    /// <summary>
    /// 現在のチャージ率(0.0 ~ 1.0)を取得する。
    /// 溜め中でない場合は 0 を返す。
    /// </summary>
    public float GetCurrentChargeRatio()
    {
        if (!_isCharging)
        {
            return 0f;
        }

        float chargeDuration = Time.time - _chargeStartTime;
        return Mathf.Clamp01(chargeDuration / maxChargeTime);
    }

この関数を UI ゲージ更新用のコンポーネントから呼び出して、
Image.fillAmount = chargeShot.GetCurrentChargeRatio(); のように使えば、
視覚的に「今どれくらい貯まっているか」をプレイヤーに伝えられます。

溜め撃ちロジックを1つのコンポーネントに閉じ込めておくと、
演出や入力方法を変えたくなったときも、ここを中心に改造するだけで済むのでおすすめです。