【Unity】AmmoCounter (残弾管理) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

UnityでシューティングやFPSを作り始めると、つい「とりあえず動かしたい!」という気持ちから、Update() の中に「入力チェック」「弾の発射」「残弾の計算」「リロード処理」「UI更新」などを全部書いてしまいがちです。

動きはするのですが、時間が経つと

  • どこで弾数が減っているのか分からない
  • リロード仕様を変えたいのに、他の処理まで巻き込んでバグる
  • プレイヤーと敵で同じような弾管理コードをコピペしてしまう

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

そこで今回は「弾数管理」という責務だけにフォーカスした小さなコンポーネント AmmoCounter を作ってみましょう。
「発射できるかどうかの判定」「撃ったときに残弾を減らす」「リロード処理を提供する」といったロジックをこのコンポーネントに閉じ込めておくことで、プレイヤーや敵のスクリプトは 「撃ちたい」だけを伝える シンプルな形にできます。

【Unity】残弾管理をコンポーネント化!「AmmoCounter」コンポーネント

ここでは、以下のような機能を持つ AmmoCounter を実装します。

  • 最大弾数・現在弾数の管理
  • 弾が0なら発射不可にする判定
  • リロード時間付きのリロード処理
  • 発射時・リロード開始/完了時にイベントを発火(他コンポーネントからフックしやすくする)

フルコード:AmmoCounter.cs


using System;
using UnityEngine;

namespace Sample.Shooting
{
    /// <summary>
    /// シンプルな残弾管理コンポーネント。
    /// - 弾数管理
    /// - 発射可能判定
    /// - リロード処理
    /// を担当します。
    /// 
    /// 「いつ撃つか」「いつリロードボタンを押したか」などの入力は、
    /// 別コンポーネントから呼び出してもらう想定です。
    /// </summary>
    public class AmmoCounter : MonoBehaviour
    {
        [Header("弾数設定")]
        [Tooltip("マガジン1本あたりの最大弾数")]
        [SerializeField] private int maxAmmo = 30;

        [Tooltip("ゲーム開始時の初期弾数(0以下なら maxAmmo で初期化)")]
        [SerializeField] private int initialAmmo = -1;

        [Header("リロード設定")]
        [Tooltip("リロードにかかる時間(秒)")]
        [SerializeField] private float reloadDuration = 2.0f;

        [Tooltip("自動リロードするか?(弾が0になったとき)")]
        [SerializeField] private bool autoReloadOnEmpty = true;

        [Header("デバッグ")]
        [Tooltip("現在の弾数(インスペクター表示用)")]
        [SerializeField] private int currentAmmo;

        [Tooltip("現在リロード中かどうか(読み取り専用)")]
        [SerializeField] private bool isReloading;

        // リロード処理を管理するための内部タイマー
        private float reloadTimer = 0f;

        #region 公開イベント(他コンポーネントから購読して使う)

        /// <summary>
        /// 1発発射されたときに呼ばれるイベント。
        /// 発射に成功したときのみ発火します。
        /// 引数: 残弾数
        /// </summary>
        public event Action<int> OnFired;

        /// <summary>
        /// リロードが開始されたときに呼ばれるイベント。
        /// </summary>
        public event Action OnReloadStarted;

        /// <summary>
        /// リロードが完了したときに呼ばれるイベント。
        /// 引数: リロード後の弾数
        /// </summary>
        public event Action<int> OnReloadFinished;

        /// <summary>
        /// 発射しようとしたが弾が0だったときに呼ばれるイベント。
        /// </summary>
        public event Action OnEmptyFire;

        #endregion

        #region プロパティ(読み取り専用)

        /// <summary>
        /// 現在の弾数
        /// </summary>
        public int CurrentAmmo => currentAmmo;

        /// <summary>
        /// 最大弾数
        /// </summary>
        public int MaxAmmo => maxAmmo;

        /// <summary>
        /// 現在リロード中かどうか
        /// </summary>
        public bool IsReloading => isReloading;

        /// <summary>
        /// 今発射可能かどうか
        /// - 弾が1以上
        /// - リロード中ではない
        /// の両方を満たすとき true
        /// </summary>
        public bool CanFire => !isReloading && currentAmmo > 0;

        #endregion

        private void Awake()
        {
            // 初期弾数が0以下なら maxAmmo を使う
            if (initialAmmo <= 0)
            {
                currentAmmo = maxAmmo;
            }
            else
            {
                currentAmmo = Mathf.Clamp(initialAmmo, 0, maxAmmo);
            }

            isReloading = false;
            reloadTimer = 0f;
        }

        private void Update()
        {
            // リロード中ならタイマーを進める
            if (isReloading)
            {
                UpdateReloadTimer();
            }
        }

        /// <summary>
        /// 発射要求。
        /// 呼び出し元(プレイヤーや敵)は「撃ちたいとき」にこのメソッドを呼びます。
        /// </summary>
        /// <returns>発射に成功したら true / 失敗したら false</returns>
        public bool TryFire()
        {
            // リロード中は撃てない
            if (isReloading)
            {
                return false;
            }

            // 弾切れ
            if (currentAmmo <= 0)
            {
                OnEmptyFire?.Invoke();

                // 自動リロード設定が有効なら、ここでリロード開始
                if (autoReloadOnEmpty)
                {
                    StartReload();
                }

                return false;
            }

            // 1発消費
            currentAmmo = Mathf.Max(0, currentAmmo - 1);

            // イベント発火
            OnFired?.Invoke(currentAmmo);

            // ここで弾が0になった場合も、自動リロードが有効なら開始
            if (currentAmmo == 0 && autoReloadOnEmpty)
            {
                StartReload();
            }

            return true;
        }

        /// <summary>
        /// 明示的にリロードを開始する。
        /// すでにリロード中 / すでに満タン の場合は何もしない。
        /// </summary>
        /// <returns>リロードを開始できたら true / できなければ false</returns>
        public bool StartReload()
        {
            // すでにリロード中なら無視
            if (isReloading)
            {
                return false;
            }

            // すでに満タンならリロード不要
            if (currentAmmo >= maxAmmo)
            {
                return false;
            }

            isReloading = true;
            reloadTimer = 0f;

            OnReloadStarted?.Invoke();

            return true;
        }

        /// <summary>
        /// リロードタイマーを進める内部処理。
        /// Update から呼び出されます。
        /// </summary>
        private void UpdateReloadTimer()
        {
            reloadTimer += Time.deltaTime;

            if (reloadTimer >= reloadDuration)
            {
                FinishReload();
            }
        }

        /// <summary>
        /// リロード完了処理。
        /// 内部からのみ呼び出されます。
        /// </summary>
        private void FinishReload()
        {
            isReloading = false;
            reloadTimer = 0f;

            // マガジンを満タンに
            currentAmmo = maxAmmo;

            OnReloadFinished?.Invoke(currentAmmo);
        }

        /// <summary>
        /// 弾数を手動でセットしたいとき用のヘルパー。
        /// デバッグ用や、特殊なアイテム取得時などに使えます。
        /// </summary>
        /// <param name="value">新しい弾数</param>
        public void SetAmmo(int value)
        {
            currentAmmo = Mathf.Clamp(value, 0, maxAmmo);
        }

        /// <summary>
        /// 弾数を増やすヘルパー。
        /// マガジンの上限を超えないようにクランプします。
        /// </summary>
        /// <param name="amount">増やしたい量(負数でも可)</param>
        public void AddAmmo(int amount)
        {
            SetAmmo(currentAmmo + amount);
        }
    }
}

使い方の手順

ここからは、具体的な使い方を「プレイヤーが銃を撃つ」ケースで見ていきます。
敵AIやタレット、動く床に設置した砲台などでも、同じコンポーネントを使い回せる構成になっています。

  1. AmmoCounter コンポーネントを用意する
    • プロジェクト内で AmmoCounter.cs を作成し、上記のコードをコピペして保存します。
    • Unity に戻ると自動的にコンパイルされます。
  2. プレイヤー(または武器)オブジェクトにアタッチする
    • ヒエラルキーで PlayerGun オブジェクトを選択。
    • Add Component から AmmoCounter を追加します。
    • Max AmmoReload Duration をインスペクターで調整します(例:30発、2秒)。
  3. 入力を受け取るコンポーネントから AmmoCounter を呼び出す

    ここでは簡易的な例として、マウス左クリックで発射、R キーでリロードするスクリプトを示します。
    (Unity Input System を使っても良いですが、ここでは分かりやすさ重視で Input.GetMouseButton / Input.GetKeyDown を使用します)

    
    using UnityEngine;
    using Sample.Shooting;
    
    /// <summary>
    /// プレイヤーの射撃入力を受け取り、AmmoCounter に指示を出すだけのコンポーネント。
    /// 「入力」と「弾管理」を分離する例です。
    /// </summary>
    [RequireComponent(typeof(AmmoCounter))]
    public class PlayerShooter : MonoBehaviour
    {
        [Header("射撃設定")]
        [Tooltip("連射間隔(秒)")]
        [SerializeField] private float fireInterval = 0.1f;
    
        [Tooltip("発射位置(銃口など)")]
        [SerializeField] private Transform muzzleTransform;
    
        [Tooltip("弾丸プレハブ(Rigidbody付きなど)")]
        [SerializeField] private GameObject bulletPrefab;
    
        private AmmoCounter ammoCounter;
        private float fireTimer = 0f;
    
        private void Awake()
        {
            ammoCounter = GetComponent<AmmoCounter>();
    
            // AmmoCounter のイベントに登録してみる例
            ammoCounter.OnFired += OnAmmoFired;
            ammoCounter.OnReloadStarted += OnReloadStarted;
            ammoCounter.OnReloadFinished += OnReloadFinished;
            ammoCounter.OnEmptyFire += OnEmptyFire;
        }
    
        private void Update()
        {
            fireTimer += Time.deltaTime;
    
            // 左クリックで射撃
            if (Input.GetMouseButton(0))
            {
                TryShoot();
            }
    
            // R キーでリロード
            if (Input.GetKeyDown(KeyCode.R))
            {
                ammoCounter.StartReload();
            }
        }
    
        private void TryShoot()
        {
            // 連射間隔チェック
            if (fireTimer < fireInterval)
            {
                return;
            }
    
            // 発射を試みる
            bool fired = ammoCounter.TryFire();
            if (!fired)
            {
                // 弾切れやリロード中など
                return;
            }
    
            fireTimer = 0f;
    
            // 実際の弾生成(ここはゲームに合わせて自由に改造できます)
            if (bulletPrefab != null && muzzleTransform != null)
            {
                Instantiate(
                    bulletPrefab,
                    muzzleTransform.position,
                    muzzleTransform.rotation
                );
            }
        }
    
        #region AmmoCounter イベントのハンドラ例
    
        private void OnAmmoFired(int remain)
        {
            Debug.Log($"発射! 残弾: {remain}");
        }
    
        private void OnReloadStarted()
        {
            Debug.Log("リロード開始");
        }
    
        private void OnReloadFinished(int afterReload)
        {
            Debug.Log($"リロード完了! 弾数: {afterReload}");
        }
    
        private void OnEmptyFire()
        {
            Debug.Log("カチッ(弾切れ)");
        }
    
        #endregion
    }
    

    このように、PlayerShooter は「入力」と「弾丸の生成」だけに責務を絞り、弾数管理は AmmoCounter に丸投げしています。

  4. 敵やタレットにもそのまま流用する

    例えば、自動で一定間隔で撃つ敵タレットを作りたい場合、AmmoCounter をそのまま使い回せます。

    
    using UnityEngine;
    using Sample.Shooting;
    
    /// <summary>
    /// 一定間隔で自動射撃する簡単なタレット。
    /// AmmoCounter に発射を依頼するだけにしてあります。
    /// </summary>
    [RequireComponent(typeof(AmmoCounter))]
    public class AutoTurretShooter : MonoBehaviour
    {
        [SerializeField] private float fireInterval = 0.5f;
        [SerializeField] private Transform muzzleTransform;
        [SerializeField] private GameObject bulletPrefab;
    
        private AmmoCounter ammoCounter;
        private float timer;
    
        private void Awake()
        {
            ammoCounter = GetComponent<AmmoCounter>();
        }
    
        private void Update()
        {
            timer += Time.deltaTime;
            if (timer < fireInterval)
            {
                return;
            }
    
            timer = 0f;
    
            // AmmoCounter に発射を依頼
            if (ammoCounter.TryFire())
            {
                if (bulletPrefab != null && muzzleTransform != null)
                {
                    Instantiate(bulletPrefab, muzzleTransform.position, muzzleTransform.rotation);
                }
            }
            else
            {
                // 自動リロードが無効な場合、ここで StartReload() を呼ぶなども可能
                ammoCounter.StartReload();
            }
        }
    }
    

    プレイヤー用・敵用で「発射タイミング」のロジックは違いますが、「弾数の減り方・リロード仕様」は共通の AmmoCounter が担当してくれるので、仕様変更があっても1か所直すだけで済みます。

メリットと応用

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

  • プレハブの再利用性が高まる
    プレイヤー、敵、タレット、動く床に乗った砲台など、どのオブジェクトでも同じ AmmoCounter をアタッチするだけで「残弾管理」の仕様を共有できます。
    レベルデザイン時には、Max AmmoReload Duration をプレハブごとに変えるだけで、強さやプレッシャーの調整ができます。
  • 責務の分離で Godクラス化を防げる
    プレイヤーのスクリプトが「移動」「カメラ制御」「HP管理」「弾管理」「UI更新」…と肥大化していくのを防げます。
    AmmoCounter は「弾管理だけ」を知っていればよく、入力や弾丸生成のことは何も知りません。この分離のおかげで、バグ調査もしやすくなります。
  • テストやデバッグがしやすい
    弾数やリロード時間をインスペクターから直接いじれるので、ゲームバランス調整が楽になります。
    イベント(OnFired など)にログ出力やUI更新をつなげておけば、「どのタイミングで何が起きたか」が視覚的に分かりやすくなります。
  • UIやエフェクトと疎結合にできる
    OnFired / OnReloadStarted / OnReloadFinished を使えば、
    • 残弾UIテキストの更新
    • リロードアニメーションの再生
    • SE(発射音・空撃ち音・リロード音)の再生

    などを、別コンポーネントから簡単にフックできます。
    UIや演出の仕様が変わっても、AmmoCounter 本体のコードを触る必要がありません。

最後に、簡単な「改造案」を1つ示します。例えば、リロード完了時にだけエフェクトを出したい場合、AmmoCounter を直接いじるのではなく、専用のリスナーコンポーネントを用意するとキレイです。


using UnityEngine;
using Sample.Shooting;

/// <summary>
/// AmmoCounter のイベントを受け取って、リロード完了時にエフェクトを再生する例。
/// AmmoCounter 本体には一切手を加えず、演出だけを差し込めます。
/// </summary>
[RequireComponent(typeof(AmmoCounter))]
public class ReloadEffectPlayer : MonoBehaviour
{
    [SerializeField] private ParticleSystem reloadEffect;

    private AmmoCounter ammoCounter;

    private void Awake()
    {
        ammoCounter = GetComponent<AmmoCounter>();
        ammoCounter.OnReloadFinished += PlayReloadEffect;
    }

    private void PlayReloadEffect(int ammoAfterReload)
    {
        if (reloadEffect == null) return;

        reloadEffect.Play();
    }
}

このように、AmmoCounter は「残弾の状態マシン」だけに集中させておき、周辺の演出・入力・AIは別コンポーネントで組み合わせると、プロジェクトが大きくなっても破綻しにくい構成になります。
小さな責務に分割しつつ、コンポーネント同士をゆるくつなぐ設計を意識していきましょう。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

URLをコピーしました!