Unityを触り始めたころ、つい「とりあえず全部Updateに書く」やり方をしてしまいがちですよね。
プレイヤー操作、HP管理、経験値、レベルアップ演出、UI更新……全部ひとつのスクリプトに押し込んでしまうと、次のような問題が出てきます。

  • 機能追加のたびに巨大なスクリプトをスクロールしまくる
  • どこでバグっているか追いづらい(責任範囲が広すぎる)
  • 別のプロジェクトに「経験値だけ」使い回したいのに、他の処理と密結合で取り出せない

そこでこの記事では、「経験値とレベルアップ判定だけ」を担当する小さなコンポーネントとして、
ExperienceGainer を用意してみます。

敵を倒したときに「倒したよ!」というシグナル(イベント)を受け取り、
経験値を加算してレベルアップ判定を行うところまでを、このコンポーネントに任せてしまいましょう。

【Unity】シグナル駆動でスッキリ経験値管理!「ExperienceGainer」コンポーネント

まずは完成コードから載せます。
この記事のコードだけで動くように、敵側が発行するシグナル(イベント)用の仲介コンポーネントも含めてあります。


ExperienceGainer.cs(経験値管理コンポーネント)


using System;
using UnityEngine;

namespace Sample.Experience
{
    /// <summary>
    /// 経験値とレベルを管理するコンポーネント。
    /// 「敵を倒した」シグナルを受け取り、EXP加算とレベルアップ判定を行う。
    /// 
    /// - SRP(単一責任の原則)に従い、「経験値とレベル管理」に責務を限定
    /// - 敵の実装やプレイヤーの移動ロジックから独立させる
    /// </summary>
    public class ExperienceGainer : MonoBehaviour
    {
        // --- 設定値(インスペクターから調整) ----------------------------

        [Header("初期ステータス")]
        [SerializeField]
        private int _initialLevel = 1;

        [SerializeField]
        private int _initialExp = 0;

        [Header("レベルアップ設定")]
        [Tooltip("レベル1 → 2 に必要な経験値。後続レベルは倍率で増加します。")]
        [SerializeField]
        private int _baseExpToNextLevel = 100;

        [Tooltip("レベルが1上がるごとに必要経験値を何倍にするか")]
        [SerializeField]
        private float _expRequiredMultiplier = 1.3f;

        [Header("レベル上限")]
        [SerializeField]
        private int _maxLevel = 99;

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

        // --- ランタイム状態 -----------------------------------------------

        [SerializeField, Tooltip("現在のレベル(デバッグ確認用)")]
        private int _currentLevel;

        [SerializeField, Tooltip("現在の経験値(デバッグ確認用)")]
        private int _currentExp;

        [SerializeField, Tooltip("次のレベルまでに必要な経験値(デバッグ確認用)")]
        private int _expToNextLevel;

        // --- 外部に公開する読み取り専用プロパティ ------------------------

        public int CurrentLevel => _currentLevel;
        public int CurrentExp => _currentExp;
        public int ExpToNextLevel => _expToNextLevel;
        public bool IsMaxLevel => _currentLevel >= _maxLevel;

        // --- イベント(UIや演出用にフックできる) -------------------------

        /// <summary>経験値が増加したときに発火するイベント</summary>
        public event Action<int, int> OnExpChanged;
        /// <summary>レベルアップしたときに発火するイベント (oldLevel, newLevel)</summary>
        public event Action<int, int> OnLevelUp;

        // -------------------------------------------------------------------

        private void Awake()
        {
            InitializeStatus();
        }

        /// <summary>
        /// 初期ステータスをセットアップする。
        /// シーン開始時やリスポーン時に呼び出してもOK。
        /// </summary>
        public void InitializeStatus()
        {
            _currentLevel = Mathf.Clamp(_initialLevel, 1, _maxLevel);
            _currentExp = Mathf.Max(0, _initialExp);
            _expToNextLevel = CalculateExpToNextLevel(_currentLevel);

            // 初期状態を通知しておくと、UI側で初期表示に使える
            OnExpChanged?.Invoke(_currentExp, _expToNextLevel);
        }

        /// <summary>
        /// 敵を倒したシグナルから呼ばれる想定のメソッド。
        /// 加算する経験値量を受け取り、内部でレベルアップ判定を行う。
        /// </summary>
        /// <param name="gainedExp">獲得した経験値量</param>
        public void GainExp(int gainedExp)
        {
            if (gainedExp <= 0)
            {
                // マイナスやゼロは無視(デバッグしやすいようにログ出力してもよい)
                return;
            }

            if (IsMaxLevel)
            {
                // レベル上限に達していたら、経験値は増やさない設計
                return;
            }

            _currentExp += gainedExp;

            // レベルアップ判定をループで行う(1度に複数レベル上がるケースを考慮)
            bool leveledUp = false;
            while (!IsMaxLevel && _currentExp >= _expToNextLevel)
            {
                _currentExp -= _expToNextLevel;
                int oldLevel = _currentLevel;
                _currentLevel = Mathf.Min(_currentLevel + 1, _maxLevel);

                // 次のレベルの必要EXPを再計算
                _expToNextLevel = CalculateExpToNextLevel(_currentLevel);

                leveledUp = true;

                // レベルアップイベント発火
                OnLevelUp?.Invoke(oldLevel, _currentLevel);

                if (_logOnLevelUp)
                {
                    Debug.Log(
                        $"[ExperienceGainer] Level Up! {oldLevel} → {_currentLevel} / Next Exp: {_expToNextLevel}");
                }

                // 上限に達したらループ終了
                if (IsMaxLevel)
                {
                    _currentExp = 0; // 上限到達後は経験値を0固定にする設計
                    break;
                }
            }

            // 経験値変化イベント発火
            OnExpChanged?.Invoke(_currentExp, _expToNextLevel);

            if (!leveledUp && _logOnLevelUp)
            {
                // デバッグ用に獲得ログを出したいときはここで
                // Debug.Log($"[ExperienceGainer] Gained {gainedExp} EXP. Current: {_currentExp}/{_expToNextLevel}");
            }
        }

        /// <summary>
        /// レベルごとの必要経験値を計算する。
        /// ここを差し替えれば、任意のカーブに変更可能。
        /// </summary>
        private int CalculateExpToNextLevel(int level)
        {
            if (level >= _maxLevel)
            {
                // 上限レベルではこれ以上上がらないので0を返す
                return 0;
            }

            // 例: base * (multiplier ^ (level - 1))
            float required = _baseExpToNextLevel * Mathf.Pow(_expRequiredMultiplier, level - 1);
            return Mathf.Max(1, Mathf.RoundToInt(required));
        }
    }
}

EnemyDefeatSignal.cs(敵が倒れたことを通知するコンポーネント)

次に、敵側から「倒された」シグナルを発行するためのコンポーネントです。
実際のゲームでは HP 管理やダメージ判定は別コンポーネントにしておき、
「倒れたタイミング」でこの NotifyDefeated() を呼ぶイメージです。


using System;
using UnityEngine;

namespace Sample.Experience
{
    /// <summary>
    /// 「敵が倒された」ことをシグナルとして発行するコンポーネント。
    /// 
    /// - 実際のHP管理やダメージ判定は別コンポーネントに任せる前提
    /// - 倒された瞬間に NotifyDefeated() を呼び出すことで、経験値システムなどに通知する
    /// </summary>
    public class EnemyDefeatSignal : MonoBehaviour
    {
        [Header("撃破時に付与する経験値")]
        [SerializeField]
        private int _expReward = 30;

        /// <summary>
        /// 敵が倒されたときに発火するイベント。
        /// 引数は「この敵が与える経験値量」。
        /// </summary>
        public event Action<int> OnDefeated;

        private bool _isDefeated = false;

        /// <summary>
        /// 外部(HPコンポーネントなど)から呼び出される想定のメソッド。
        /// 一度だけシグナルを発行し、その後は重複して呼ばれないようにする。
        /// </summary>
        public void NotifyDefeated()
        {
            if (_isDefeated) return;

            _isDefeated = true;

            // イベント発火(購読者に経験値量を伝える)
            OnDefeated?.Invoke(_expReward);

            // ここで Destroy(gameObject); などを呼んでもよいが、
            // 破棄タイミングは別コンポーネントに任せてもOK。
            // Destroy(gameObject);
        }

        /// <summary>
        /// デバッグ用:インスペクターから右クリックで呼べるようにしておくと便利。
        /// (Unity 2022 以降の ContextMenu 属性)
        /// </summary>
        [ContextMenu("Debug/NotifyDefeated")]
        private void DebugNotifyDefeated()
        {
            NotifyDefeated();
        }
    }
}

EnemyDefeatToExpBridge.cs(敵の撃破シグナルをプレイヤーのExperienceGainerに橋渡し)

最後に、敵のシグナルとプレイヤーの ExperienceGainer を接続するブリッジコンポーネントです。
「敵の OnDefeated を購読して、プレイヤーの GainExp を呼ぶ」だけの小さい責務に分けています。


using UnityEngine;

namespace Sample.Experience
{
    /// <summary>
    /// 敵の撃破シグナルと、プレイヤーの ExperienceGainer をつなぐブリッジ。
    /// 
    /// - 敵オブジェクトにアタッチして使う想定
    /// - Awake で EnemyDefeatSignal を取得し、OnDefeated イベントに購読
    /// - 撃破時に、指定した ExperienceGainer へ GainExp を送る
    /// </summary>
    [RequireComponent(typeof(EnemyDefeatSignal))]
    public class EnemyDefeatToExpBridge : MonoBehaviour
    {
        [Header("経験値を与える対象(例:プレイヤー)")]
        [SerializeField]
        private ExperienceGainer _targetExperienceGainer;

        private EnemyDefeatSignal _enemyDefeatSignal;

        private void Awake()
        {
            _enemyDefeatSignal = GetComponent<EnemyDefeatSignal>();

            if (_enemyDefeatSignal == null)
            {
                Debug.LogError("[EnemyDefeatToExpBridge] EnemyDefeatSignal が見つかりません。");
                enabled = false;
                return;
            }

            if (_targetExperienceGainer == null)
            {
                Debug.LogWarning("[EnemyDefeatToExpBridge] Target ExperienceGainer が未設定です。");
            }
        }

        private void OnEnable()
        {
            if (_enemyDefeatSignal != null)
            {
                _enemyDefeatSignal.OnDefeated += HandleEnemyDefeated;
            }
        }

        private void OnDisable()
        {
            if (_enemyDefeatSignal != null)
            {
                _enemyDefeatSignal.OnDefeated -= HandleEnemyDefeated;
            }
        }

        /// <summary>
        /// 敵が倒されたときに呼ばれるコールバック。
        /// 経験値を ExperienceGainer に中継するだけのシンプルな責務。
        /// </summary>
        private void HandleEnemyDefeated(int expReward)
        {
            if (_targetExperienceGainer == null)
            {
                Debug.LogWarning(
                    "[EnemyDefeatToExpBridge] Target ExperienceGainer が未設定のため、EXPを付与できません。");
                return;
            }

            _targetExperienceGainer.GainExp(expReward);
        }

        /// <summary>
        /// ランタイム中にターゲットを差し替えたい場合用のヘルパー。
        /// (マルチプレイや仲間キャラへの付け替えなど)
        /// </summary>
        public void SetTarget(ExperienceGainer newTarget)
        {
            _targetExperienceGainer = newTarget;
        }
    }
}

使い方の手順

ここからは、具体的な使い方をプレイヤーと敵の例で説明します。

手順① プレイヤーに ExperienceGainer を付ける

  1. シーン内のプレイヤーオブジェクト(例: Player)を選択します。
  2. Add Component ボタンから ExperienceGainer を追加します。
  3. インスペクターで以下を設定します。
    • Initial Level:スタートレベル(例: 1)
    • Initial Exp:スタート経験値(例: 0)
    • Base Exp To Next Level:レベル1→2に必要な経験値(例: 100)
    • Exp Required Multiplier:レベルごとの必要経験値倍率(例: 1.3)
    • Max Level:レベル上限(例: 50)

UIでレベルや経験値バーを表示したい場合は、
別コンポーネントで ExperienceGainer.OnExpChangedOnLevelUp に購読して、
スライダーやテキストを更新してあげる形にすると、きれいに責務分離できます。

手順② 敵プレハブに EnemyDefeatSignal を付ける

  1. 敵のプレハブ(例: EnemyGoblin)を開きます。
  2. Add Component から EnemyDefeatSignal を追加します。
  3. Exp Reward に、この敵を倒したときの獲得経験値(例: 30)を設定します。
  4. 敵のHPを管理しているスクリプトがある場合、HPが0以下になった瞬間に
    GetComponent<EnemyDefeatSignal>().NotifyDefeated();

    を呼ぶようにします。

もしまだHP管理を作っていない場合は、
とりあえず EnemyDefeatSignal のインスペクター右クリックメニューの
Debug/NotifyDefeated から手動で発火して動作確認してもOKです。

手順③ 敵に EnemyDefeatToExpBridge を付けてプレイヤーを紐付ける

  1. 同じ敵プレハブに EnemyDefeatToExpBridge コンポーネントを追加します。
  2. インスペクターの Target Experience Gainer に、シーン上のプレイヤーオブジェクトをドラッグ&ドロップします。
    • このとき、プレイヤー側に ExperienceGainer が付いている必要があります。

これで、敵が倒されたときに EnemyDefeatSignalEnemyDefeatToExpBridgeExperienceGainer という流れで、
自動的に経験値が加算されるようになります。

手順④ 実際のゲーム内での例

  • プレイヤー
    • PlayerMovement(移動)
    • PlayerAttack(攻撃)
    • ExperienceGainer(経験値・レベル)
    • PlayerStatusUI(HP/MP/EXPバーの更新)

    のように、役割ごとにコンポーネントを分けておくと、経験値システムだけ別プロジェクトに持っていくのも簡単です。

  • 敵(ゴブリン)
    • EnemyHealth(HP管理)
    • EnemyAI(行動パターン)
    • EnemyDefeatSignal(撃破シグナル)
    • EnemyDefeatToExpBridge(プレイヤーへのEXP付与)
  • 動く床やギミック
    • 敵ではないが、「乗ったら経験値がもらえる床」などを作りたい場合も、
      そのオブジェクトに EnemyDefeatSignal 相当の「ExpGrantSignal」的なコンポーネントを付けて、
      ExperienceGainer.GainExp() を呼ぶだけで実装できます。

メリットと応用

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

  • プレハブ管理が楽になる
    • 「この敵は経験値50」「このボスは経験値1000」など、
      すべてプレハブの EnemyDefeatSignalExp Reward で完結します。
    • レベルアップの計算式は、プレイヤー側の ExperienceGainer だけを見ればよいので、
      「敵の数が増えたらどこを直せばいいかわからない」という状態を防げます。
  • レベルデザインの試行錯誤がしやすい
    • Base Exp To Next LevelExp Required Multiplier を変えるだけで、
      必要経験値カーブをガラッと変えられます。
    • 敵ごとの経験値量もプレハブ単位で調整できるので、
      「このステージだけ経験値多めにしてサクサク上がるようにする」といった調整も簡単です。
  • 責務が小さいのでテストしやすい
    • ExperienceGainer は「経験値とレベル」のみを扱うので、
      エディタ拡張やテストコードから GainExp() を叩いて動作確認しやすいです。
    • UIやエフェクトは OnLevelUp イベントにぶら下げるだけで済むため、
      ロジックと演出の分離がきれいにできます。

改造案:レベルアップ時に自動でHP最大値を上げる

応用として、レベルアップしたときにプレイヤーの最大HPを上げる処理を追加してみましょう。
HP管理コンポーネント(例: PlayerHealth)があるとして、ExperienceGainerOnLevelUp に購読するイメージです。


using UnityEngine;

namespace Sample.Experience
{
    /// <summary>
    /// レベルアップ時に最大HPを上昇させるサンプル。
    /// Player オブジェクトにアタッチし、ExperienceGainer と PlayerHealth を紐付ける。
    /// </summary>
    public class LevelUpHpBooster : MonoBehaviour
    {
        [SerializeField]
        private ExperienceGainer _experienceGainer;

        [SerializeField]
        private PlayerHealth _playerHealth;

        [SerializeField, Tooltip("レベルアップごとに増加させる最大HP量")]
        private int _maxHpIncreasePerLevel = 10;

        private void Awake()
        {
            if (_experienceGainer != null)
            {
                _experienceGainer.OnLevelUp += HandleLevelUp;
            }
        }

        private void OnDestroy()
        {
            if (_experienceGainer != null)
            {
                _experienceGainer.OnLevelUp -= HandleLevelUp;
            }
        }

        private void HandleLevelUp(int oldLevel, int newLevel)
        {
            if (_playerHealth == null) return;

            // レベル差分分だけHPを増やす(まとめて複数レベル上がるケースに対応)
            int levelDiff = Mathf.Max(1, newLevel - oldLevel);
            int increaseAmount = _maxHpIncreasePerLevel * levelDiff;

            _playerHealth.IncreaseMaxHp(increaseAmount);
        }
    }

    /// <summary>
    /// 非常にシンプルなHP管理の例。
    /// 実際のゲームではもっと充実させてOK。
    /// </summary>
    public class PlayerHealth : MonoBehaviour
    {
        [SerializeField]
        private int _maxHp = 100;

        [SerializeField]
        private int _currentHp = 100;

        public int MaxHp => _maxHp;
        public int CurrentHp => _currentHp;

        public void IncreaseMaxHp(int amount)
        {
            _maxHp += amount;
            _currentHp = _maxHp; // レベルアップ時に全回復させる例
            Debug.Log($"[PlayerHealth] Max HP increased by {amount}. New Max HP: {_maxHp}");
        }
    }
}

こんな感じで、ExperienceGainer はあくまで「経験値とレベルアップ判定」に責務を限定し、
レベルアップ時の効果(HP上昇、スキル解放、エフェクト再生など)は別コンポーネントにどんどん切り出していくと、
後から読み返しても分かりやすいプロジェクト構成になります。