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 を付ける
- シーン内のプレイヤーオブジェクト(例:
Player)を選択します。 Add ComponentボタンからExperienceGainerを追加します。- インスペクターで以下を設定します。
- Initial Level:スタートレベル(例: 1)
- Initial Exp:スタート経験値(例: 0)
- Base Exp To Next Level:レベル1→2に必要な経験値(例: 100)
- Exp Required Multiplier:レベルごとの必要経験値倍率(例: 1.3)
- Max Level:レベル上限(例: 50)
UIでレベルや経験値バーを表示したい場合は、
別コンポーネントで ExperienceGainer.OnExpChanged や OnLevelUp に購読して、
スライダーやテキストを更新してあげる形にすると、きれいに責務分離できます。
手順② 敵プレハブに EnemyDefeatSignal を付ける
- 敵のプレハブ(例:
EnemyGoblin)を開きます。 Add ComponentからEnemyDefeatSignalを追加します。- Exp Reward に、この敵を倒したときの獲得経験値(例: 30)を設定します。
- 敵のHPを管理しているスクリプトがある場合、HPが0以下になった瞬間に
GetComponent<EnemyDefeatSignal>().NotifyDefeated();を呼ぶようにします。
もしまだHP管理を作っていない場合は、
とりあえず EnemyDefeatSignal のインスペクター右クリックメニューの
Debug/NotifyDefeated から手動で発火して動作確認してもOKです。
手順③ 敵に EnemyDefeatToExpBridge を付けてプレイヤーを紐付ける
- 同じ敵プレハブに
EnemyDefeatToExpBridgeコンポーネントを追加します。 - インスペクターの Target Experience Gainer に、シーン上のプレイヤーオブジェクトをドラッグ&ドロップします。
- このとき、プレイヤー側に
ExperienceGainerが付いている必要があります。
- このとき、プレイヤー側に
これで、敵が倒されたときに EnemyDefeatSignal → EnemyDefeatToExpBridge → ExperienceGainer という流れで、
自動的に経験値が加算されるようになります。
手順④ 実際のゲーム内での例
- プレイヤー:
PlayerMovement(移動)PlayerAttack(攻撃)ExperienceGainer(経験値・レベル)PlayerStatusUI(HP/MP/EXPバーの更新)
のように、役割ごとにコンポーネントを分けておくと、経験値システムだけ別プロジェクトに持っていくのも簡単です。
- 敵(ゴブリン):
EnemyHealth(HP管理)EnemyAI(行動パターン)EnemyDefeatSignal(撃破シグナル)EnemyDefeatToExpBridge(プレイヤーへのEXP付与)
- 動く床やギミック:
- 敵ではないが、「乗ったら経験値がもらえる床」などを作りたい場合も、
そのオブジェクトにEnemyDefeatSignal相当の「ExpGrantSignal」的なコンポーネントを付けて、
ExperienceGainer.GainExp()を呼ぶだけで実装できます。
- 敵ではないが、「乗ったら経験値がもらえる床」などを作りたい場合も、
メリットと応用
ExperienceGainer をコンポーネントとして切り出すことで、次のようなメリットがあります。
- プレハブ管理が楽になる:
- 「この敵は経験値50」「このボスは経験値1000」など、
すべてプレハブのEnemyDefeatSignalのExp Rewardで完結します。 - レベルアップの計算式は、プレイヤー側の
ExperienceGainerだけを見ればよいので、
「敵の数が増えたらどこを直せばいいかわからない」という状態を防げます。
- 「この敵は経験値50」「このボスは経験値1000」など、
- レベルデザインの試行錯誤がしやすい:
Base Exp To Next LevelやExp Required Multiplierを変えるだけで、
必要経験値カーブをガラッと変えられます。- 敵ごとの経験値量もプレハブ単位で調整できるので、
「このステージだけ経験値多めにしてサクサク上がるようにする」といった調整も簡単です。
- 責務が小さいのでテストしやすい:
ExperienceGainerは「経験値とレベル」のみを扱うので、
エディタ拡張やテストコードからGainExp()を叩いて動作確認しやすいです。- UIやエフェクトは
OnLevelUpイベントにぶら下げるだけで済むため、
ロジックと演出の分離がきれいにできます。
改造案:レベルアップ時に自動でHP最大値を上げる
応用として、レベルアップしたときにプレイヤーの最大HPを上げる処理を追加してみましょう。
HP管理コンポーネント(例: PlayerHealth)があるとして、ExperienceGainer の OnLevelUp に購読するイメージです。
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上昇、スキル解放、エフェクト再生など)は別コンポーネントにどんどん切り出していくと、
後から読み返しても分かりやすいプロジェクト構成になります。
