Unityの初心者~中級者でありがちなのが、Update 関数に「移動処理」「入力処理」「HP管理」「MP管理」「アニメーション制御」など、ありとあらゆる処理を書いてしまうパターンです。
動き始めは良くても、時間が経つにつれて1つのスクリプトが肥大化し、バグの原因がどこなのか分からなくなったり、別のキャラクターに流用しづらくなったりしますよね。

この記事では「MPの自動回復」というよくある仕組みを、単機能のコンポーネントとして独立させた 「ManaRecharge」コンポーネント を作ってみます。
MP管理を1つのコンポーネントに閉じ込めておけば、プレイヤーにも敵にも、ボスにも、NPCにも、同じ仕組みをポン付けで使い回せて便利です。

【Unity】放っておくだけでMPが戻る!「ManaRecharge」コンポーネント

ここでは、

  • 最大MPと現在MPを管理する
  • 指定した秒数ごとに、指定量だけMPを自動回復する
  • MPの増減を外部から安全に操作できるAPIを用意する
  • UI(スライダーなど)と簡単に連携できるイベントを用意する

といった要件を満たす ManaRecharge コンポーネントを作成します。

フルコード:ManaRecharge.cs


using System;
using UnityEngine;
using UnityEngine.Events;

namespace Sample.Mana
{
    /// <summary>
    /// MP(マナ)を時間経過で自動回復させるコンポーネント。
    /// ・最大MPと現在MPを管理
    /// ・一定間隔または毎秒で自動回復
    /// ・外部から安全に消費/回復するためのメソッドを提供
    /// ・UI更新用のイベントを発行
    /// 
    /// ※このコンポーネント単体で動作します。
    /// </summary>
    public class ManaRecharge : MonoBehaviour
    {
        // ==== 設定値(インスペクターから調整) ====

        [Header("MP 基本設定")]
        [Tooltip("最大MP")]
        [SerializeField] private float maxMana = 100f;

        [Tooltip("開始時のMP(-1以下なら最大値で開始)")]
        [SerializeField] private float startMana = -1f;

        [Header("自動回復設定")]
        [Tooltip("自動回復を有効にするか")]
        [SerializeField] private bool autoRecharge = true;

        [Tooltip("1秒あたりの自動回復量(連続回復モード)")]
        [SerializeField] private float rechargePerSecond = 5f;

        [Tooltip("一定間隔で回復する場合の回復量(intervalごと)")]
        [SerializeField] private float rechargeAmountPerTick = 10f;

        [Tooltip("一定間隔で回復する場合の間隔秒数")]
        [SerializeField] private float rechargeInterval = 1f;

        [Tooltip("true の場合:毎フレーム少しずつ回復 / false の場合:intervalごとにまとめて回復")]
        [SerializeField] private bool continuousRecharge = true;

        [Header("回復開始のディレイ")]
        [Tooltip("最後にMPを消費してから、何秒後に自動回復を再開するか")]
        [SerializeField] private float rechargeDelayAfterUse = 1.5f;

        [Header("制限設定")]
        [Tooltip("自動回復を一時停止するか(外部から制御用)")]
        [SerializeField] private bool pauseAutoRecharge = false;

        // ==== イベント ====

        [Header("イベント")]
        [Tooltip("MPが変化したときに呼ばれる(現在値, 最大値)")]
        [SerializeField] private UnityEvent<float, float> onManaChanged;

        [Tooltip("MPが枯渇したときに呼ばれる")]
        [SerializeField] private UnityEvent onManaDepleted;

        [Tooltip("MPが満タンになったときに呼ばれる")]
        [SerializeField] private UnityEvent onManaFull;

        // ==== 内部状態 ====

        /// <summary>現在のMP(外部からは読み取り専用)</summary>
        public float CurrentMana => currentMana;

        /// <summary>最大MP(読み取り専用)</summary>
        public float MaxMana => maxMana;

        // 実際のMP値
        private float currentMana;

        // 最後にMPを消費した時間
        private float lastUseTime;

        // ==== ライフサイクル ====

        private void Awake()
        {
            // 開始時のMPを決定
            if (startMana < 0f)
            {
                currentMana = maxMana; // デフォルトは最大値スタート
            }
            else
            {
                currentMana = Mathf.Clamp(startMana, 0f, maxMana);
            }

            lastUseTime = -9999f; // かなり昔に消費した扱いにしておく
        }

        private void Start()
        {
            // 初期値をイベントで通知(UIに反映したい場合など)
            InvokeManaChangedEvent();
        }

        private void Update()
        {
            if (!autoRecharge) return;
            if (pauseAutoRecharge) return;

            // MPがすでに満タンなら何もしない
            if (currentMana >= maxMana) return;

            // 最後に消費してからディレイ時間が経過しているかチェック
            if (Time.time - lastUseTime < rechargeDelayAfterUse) return;

            if (continuousRecharge)
            {
                // 毎秒一定量回復(フレームごとに少しずつ)
                float delta = rechargePerSecond * Time.deltaTime;
                if (delta > 0f)
                {
                    AddMana(delta);
                }
            }
            else
            {
                // 一定間隔ごとにまとめて回復
                // intervalの倍数をまたいだタイミングで回復させる
                // (簡易実装:Time.time を使った剰余判定)
                // ※より厳密にしたい場合は、内部にタイマーを持つ方式に差し替えてください。
                if (rechargeInterval > 0f)
                {
                    float t = Time.time - lastUseTime - rechargeDelayAfterUse;
                    // intervalの境目(誤差許容)で回復
                    float mod = t % rechargeInterval;
                    // modが前フレームより小さくなった=intervalをまたいだとみなす
                    // 前フレームのmodを保持する必要があるため、内部変数を追加
                }
            }

            // 上記の「一定間隔ごとの回復」をもう少し分かりやすく書くために、
            // 別のタイマー方式で実装し直します。
        }

        // --- ここから下で interval タイマーを実装し直す ---

        // 一定間隔回復用のタイマー
        private float rechargeTimer = 0f;

        private void LateUpdate()
        {
            if (!autoRecharge) return;
            if (pauseAutoRecharge) return;
            if (!continuousRecharge)
            {
                if (currentMana >= maxMana) return;

                // 最後の消費からの経過時間がディレイを超えているか
                float elapsedSinceUse = Time.time - lastUseTime;
                if (elapsedSinceUse < rechargeDelayAfterUse) return;

                // ディレイを過ぎたら interval タイマーを進める
                rechargeTimer += Time.deltaTime;

                if (rechargeTimer >= rechargeInterval && rechargeInterval > 0f)
                {
                    rechargeTimer -= rechargeInterval;

                    if (rechargeAmountPerTick > 0f)
                    {
                        AddMana(rechargeAmountPerTick);
                    }
                }
            }
        }

        // ==== 公開メソッド(外部からの操作用API) ====

        /// <summary>
        /// MPを消費する。消費に成功したかどうかを返す。
        /// </summary>
        /// <param name="amount">消費したいMP量(正の値)</param>
        /// <param name="allowOverConsume">
        /// true の場合:現在MPが足りなくても0まで消費する(例:必ず撃てるが、足りない分は消費されない)。<br/>
        /// false の場合:現在MPが足りないときは何もせず false を返す。
        /// </param>
        public bool ConsumeMana(float amount, bool allowOverConsume = false)
        {
            if (amount <= 0f) return false;

            // 現在MPが足りない場合
            if (currentMana < amount)
            {
                if (!allowOverConsume)
                {
                    // 消費失敗
                    return false;
                }
            }

            float before = currentMana;

            // allowOverConsume = true の場合は、0 まで下げる
            currentMana = Mathf.Max(0f, currentMana - amount);

            // 消費した時刻を記録(自動回復ディレイのため)
            lastUseTime = Time.time;

            // interval用タイマーもリセット
            rechargeTimer = 0f;

            // イベント通知
            InvokeManaChangedEvent();

            // 枯渇したらイベント発火
            if (before > 0f && currentMana <= 0f)
            {
                onManaDepleted?.Invoke();
            }

            return true;
        }

        /// <summary>
        /// MPを回復させる。自動回復からも呼ばれる。
        /// </summary>
        /// <param name="amount">回復量(正の値)</param>
        public void AddMana(float amount)
        {
            if (amount <= 0f) return;

            float before = currentMana;
            currentMana = Mathf.Clamp(currentMana + amount, 0f, maxMana);

            // 変化があったときのみイベント通知
            if (!Mathf.Approximately(before, currentMana))
            {
                InvokeManaChangedEvent();
            }

            // 満タンになった瞬間にイベント発火
            if (before < maxMana && currentMana >= maxMana)
            {
                onManaFull?.Invoke();
            }
        }

        /// <summary>
        /// MPを最大値にリセットする。
        /// </summary>
        public void RestoreFull()
        {
            float before = currentMana;
            currentMana = maxMana;
            InvokeManaChangedEvent();

            if (before < maxMana)
            {
                onManaFull?.Invoke();
            }
        }

        /// <summary>
        /// 自動回復を一時停止する。
        /// </summary>
        public void PauseAutoRecharge()
        {
            pauseAutoRecharge = true;
        }

        /// <summary>
        /// 自動回復を再開する。
        /// </summary>
        public void ResumeAutoRecharge()
        {
            pauseAutoRecharge = false;
        }

        /// <summary>
        /// 最大MPを変更し、現在MPの上限も調整する。
        /// </summary>
        public void SetMaxMana(float newMax, bool keepRatio = true)
        {
            if (newMax <= 0f) return;

            if (keepRatio && maxMana > 0f)
            {
                // 現在の割合を維持したまま最大値を変更
                float ratio = currentMana / maxMana;
                maxMana = newMax;
                currentMana = Mathf.Clamp(newMax * ratio, 0f, maxMana);
            }
            else
            {
                // 現在値はそのまま or はみ出た分はClamp
                maxMana = newMax;
                currentMana = Mathf.Clamp(currentMana, 0f, maxMana);
            }

            InvokeManaChangedEvent();
        }

        // ==== 内部メソッド ====

        /// <summary>
        /// MP変化イベントを呼び出すヘルパー
        /// </summary>
        private void InvokeManaChangedEvent()
        {
            onManaChanged?.Invoke(currentMana, maxMana);
        }
    }
}

上記はそのまま ManaRecharge.cs としてプロジェクトに追加すれば動作します。
UnityEvent を使っているので、UIスライダーやテキストと簡単に連携できます。

使い方の手順

ここでは、プレイヤーキャラにMP自動回復を付ける例をメインに、敵やギミックへの応用も含めて手順を説明します。

① スクリプトをプロジェクトに追加する

  1. Assets フォルダ内に Scripts フォルダを作成(任意)。
  2. その中に ManaRecharge.cs という名前で新規C#スクリプトを作成。
  3. エディタで開き、上のコードをまるごとコピペして保存。

② プレイヤーに ManaRecharge コンポーネントをアタッチ

  1. ヒエラルキーでプレイヤーの GameObject を選択。
  2. Add Component ボタンから ManaRecharge を検索して追加。
  3. インスペクターで以下の値を設定してみましょう:
    • Max Mana:100
    • Start Mana:-1(最大値で開始)
    • Auto Recharge:チェックON
    • Continuous Recharge:チェックON(毎秒回復モード)
    • Recharge Per Second:5(1秒あたり5回復)
    • Recharge Delay After Use:1.5(消費後1.5秒経過すると回復開始)

③ スキル発動スクリプトから MP を消費する

プレイヤーのスキル発動処理などから、ManaRecharge.ConsumeMana を呼び出してMPを減らします。
Unity6の新Input Systemなどから呼び出すイメージで、シンプルなサンプルを示します。


using UnityEngine;
using Sample.Mana; // ManaRecharge の namespace

/// <summary>
/// 非常にシンプルなスキル発動サンプル。
/// キー入力でスキルを撃ち、ManaRecharge からMPを消費する。
/// </summary>
public class SimpleSkillCaster : MonoBehaviour
{
    [SerializeField] private ManaRecharge mana;   // 同じオブジェクトか、別オブジェクトの ManaRecharge を参照
    [SerializeField] private float skillCost = 20f;

    private void Reset()
    {
        // 同じオブジェクト上から自動取得してみる
        if (mana == null)
        {
            mana = GetComponent<ManaRecharge>();
        }
    }

    private void Update()
    {
        // 例としてスペースキーでスキル発動
        if (Input.GetKeyDown(KeyCode.Space))
        {
            TryCastSkill();
        }
    }

    private void TryCastSkill()
    {
        // MPが足りなければスキル不発
        bool success = mana.ConsumeMana(skillCost, allowOverConsume: false);

        if (success)
        {
            Debug.Log("スキル発動! MP消費: " + skillCost);
            // 実際のスキル処理(エフェクト生成など)をここに書く
        }
        else
        {
            Debug.Log("MPが足りません!");
        }
    }
}

このように、スキル発動側は「MPの値」を直接いじらず、コンポーネントのAPIだけを呼ぶ形にすると、責務が分離されてスッキリします。

④ UIスライダーと連携してMPバーを表示する

MPバーを Slider で表示する例です。
まずは簡単なスクリプトを用意します。


using UnityEngine;
using UnityEngine.UI;
using Sample.Mana;

/// <summary>
/// ManaRecharge の onManaChanged イベントを受けて、
/// Slider UI を更新するコンポーネント。
/// </summary>
public class ManaSliderView : MonoBehaviour
{
    [SerializeField] private ManaRecharge mana;
    [SerializeField] private Slider slider;

    private void Reset()
    {
        // 同じオブジェクト上の Slider を自動取得
        if (slider == null)
        {
            slider = GetComponent<Slider>();
        }
    }

    /// <summary>
    /// ManaRecharge から呼ばれるイベント用メソッド。
    /// インスペクターの UnityEvent から紐づけて使う。
    /// </summary>
    public void OnManaChanged(float current, float max)
    {
        if (slider == null) return;

        slider.maxValue = max;
        slider.value = current;
    }
}

セットアップ手順は以下の通りです。

  1. Canvas 上に Slider を配置。
  2. Slider オブジェクトに ManaSliderView をアタッチ。
  3. ManaSliderViewMana に、プレイヤーの ManaRecharge をドラッグ&ドロップ。
  4. プレイヤーの ManaRecharge コンポーネントの On Mana Changed イベント欄で、
    1. + ボタンを押す。
    2. Slider オブジェクト(ManaSliderView が付いている)をドラッグ。
    3. 関数に ManaSliderView.OnManaChanged を指定。

これで、MPが変化するたびにスライダーが自動で更新されるようになります。
プレイヤーだけでなく、ボスのMPバーや、チャージ中のギミックのエネルギー残量バーなどにも流用できます。

メリットと応用

ManaRecharge をコンポーネントとして切り出すメリットはかなり大きいです。

  • プレハブ化がしやすい
    プレイヤー、敵、召喚獣、タレット、トラップなど、
    「リソースを使って何かをするオブジェクト」にそのままアタッチして使い回せます。
    MPの最大値や回復速度だけインスペクターで変えれば、簡単に個性を出せます。
  • レベルデザインが楽になる
    ステージごとに「このエリアはMPが回復しやすい」「ボス戦は回復が遅い」など、
    rechargePerSecondrechargeDelayAfterUse を調整するだけでバランス調整が可能です。
    ステージ専用のスクリプトを増やさず、パラメータ調整だけで雰囲気を変えられます。
  • Godクラス化を防げる
    「プレイヤー制御スクリプト」がMPの増減ロジックまで抱え込まず、
    ただ mana.ConsumeMana() を呼ぶだけで済むため、責務が明確になります。
  • UIとの連携が簡単
    UnityEventで現在値と最大値を投げているので、スライダーでもテキストでも、
    さらにはアニメーション用のスクリプトでも、イベント受信側を差し替えるだけでOKです。

また、MP以外の用途にも応用できます。

  • スタミナ(ダッシュや回避に使用)
  • 銃の弾数(時間で弾が補充される武器)
  • シールドエネルギー(ダメージを受けなければ自動回復するバリア)

どれも「現在値」「最大値」「自動回復」という概念は同じなので、
クラス名と表示名だけ変えればそのまま使い回せるはずです。

改造案:HPと連動してMP上限を変える

応用として、「HPが減ると最大MPも減る」ようなローグライク風の仕様を追加したい場合、ManaRecharge の外側に小さなコンポーネントを作るのがおすすめです。
例えばこんな感じです。


using UnityEngine;
using Sample.Mana;

/// <summary>
/// HP割合に応じて ManaRecharge の最大MPを変動させる例。
/// HPコンポーネント(仮)から現在HPと最大HPを受け取る想定。
/// </summary>
public class ManaMaxByHpRatio : MonoBehaviour
{
    [SerializeField] private ManaRecharge mana;
    [SerializeField] private float baseMaxMana = 100f;

    /// <summary>
    /// HPコンポーネント側の「HPが変わった」イベントから呼ばれる想定のメソッド。
    /// </summary>
    public void OnHpChanged(float currentHp, float maxHp)
    {
        if (mana == null || maxHp <= 0f) return;

        float ratio = Mathf.Clamp01(currentHp / maxHp);
        float newMax = baseMaxMana * ratio;

        // 比率維持オプションは false にして、現在MPをそのままにする
        mana.SetMaxMana(newMax, keepRatio: false);
    }
}

このように、「MP自動回復」という1つの責務を ManaRecharge に閉じ込めておくことで、
周辺の仕様(HPとの連動、ステージギミックとの連動など)は別コンポーネントとして小さく積み上げていけます。
結果として、保守しやすく拡張しやすいプロジェクトになっていきますね。