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やタレット、動く床に設置した砲台などでも、同じコンポーネントを使い回せる構成になっています。
-
AmmoCounter コンポーネントを用意する
- プロジェクト内で
AmmoCounter.csを作成し、上記のコードをコピペして保存します。 - Unity に戻ると自動的にコンパイルされます。
- プロジェクト内で
-
プレイヤー(または武器)オブジェクトにアタッチする
- ヒエラルキーで
PlayerやGunオブジェクトを選択。 Add ComponentからAmmoCounterを追加します。Max AmmoやReload Durationをインスペクターで調整します(例:30発、2秒)。
- ヒエラルキーで
-
入力を受け取るコンポーネントから 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 に丸投げしています。 -
敵やタレットにもそのまま流用する
例えば、自動で一定間隔で撃つ敵タレットを作りたい場合、
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 AmmoやReload 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は別コンポーネントで組み合わせると、プロジェクトが大きくなっても破綻しにくい構成になります。
小さな責務に分割しつつ、コンポーネント同士をゆるくつなぐ設計を意識していきましょう。




