Unityを触り始めたころは、つい「とりあえず全部Updateに書く」実装をしがちですよね。
プレイヤー入力、移動、アニメーション、HP管理、状態異常(毒・炎上・スタン…)などを1つのスクリプトに押し込んでしまうと、だんだん巨大なGodクラスになっていきます。

  • どこでHPが減っているのか分からない
  • 毒ダメージのロジックを変えたいだけなのに、プレイヤー制御まで巻き込んでバグる
  • 敵とプレイヤーで同じ毒の仕組みを使いまわしたいのに、プレイヤー専用スクリプトに埋め込んでいて再利用できない

こうした問題は、「状態異常は状態異常のコンポーネントに分離する」ことでかなりスッキリ解決できます。
この記事では、一定時間ごとに親の HealthManager を呼び出して継続ダメージを与える「毒状態」を、PoisonEffect というコンポーネントとして切り出してみましょう。

【Unity】継続ダメージをスマートに分離!「PoisonEffect」コンポーネント

ここでは、「HPを管理するコンポーネント」と、「毒ダメージを与えるコンポーネント」を完全に分離します。

  • HealthManager:ダメージを受ける側のHP管理(プレイヤーでも敵でもOK)
  • PoisonEffect:一定間隔で親の HealthManager にダメージを通知する毒状態

この2つを組み合わせることで、プレイヤーにも敵にも、動くトゲ床にも、同じ仕組みで毒を付与できるようにします。


フルコード:HealthManager と PoisonEffect

まずはそのままコピペで動く完全なコードを載せます。
Unity6(C#)でそのまま使える形になっています。

HealthManager.cs(HP管理コンポーネント)


using UnityEngine;

/// <summary>シンプルなHP管理コンポーネント</summary>
public class HealthManager : MonoBehaviour
{
    // 最大HP
    [SerializeField] private float maxHealth = 100f;
    // 現在HP
    [SerializeField] private float currentHealth = 100f;

    // 死亡したかどうか
    public bool IsDead => currentHealth <= 0f;

    private void Awake()
    {
        // シーン上で currentHealth が0以下なら最大値で初期化
        if (currentHealth <= 0f)
        {
            currentHealth = maxHealth;
        }
    }

    /// <summary>
    /// ダメージを受ける
    /// </summary>
    /// <param name="amount">ダメージ量(正の値)</param>
    public void ApplyDamage(float amount)
    {
        if (IsDead)
        {
            // すでに死亡しているなら何もしない
            return;
        }

        // ダメージは正の値を想定
        float damage = Mathf.Max(0f, amount);

        currentHealth -= damage;
        currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);

        Debug.Log($"{gameObject.name} took {damage} damage. HP: {currentHealth}/{maxHealth}");

        if (IsDead)
        {
            OnDead();
        }
    }

    /// <summary>
    /// HPを回復する
    /// </summary>
    /// <param name="amount">回復量(正の値)</param>
    public void Heal(float amount)
    {
        if (IsDead)
        {
            // 死亡後は回復しない実装
            return;
        }

        float heal = Mathf.Max(0f, amount);
        currentHealth += heal;
        currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);

        Debug.Log($"{gameObject.name} healed {heal}. HP: {currentHealth}/{maxHealth}");
    }

    /// <summary>
    /// 死亡時の処理
    /// 実際のゲームではここでアニメーション再生やリスポーン処理などを呼ぶ
    /// </summary>
    private void OnDead()
    {
        Debug.Log($"{gameObject.name} is Dead.");

        // 必要に応じてオブジェクトを無効化・破棄する
        // gameObject.SetActive(false);
        // Destroy(gameObject);
    }
}

PoisonEffect.cs(毒状態コンポーネント)


using UnityEngine;

/// <summary>
/// 一定時間ごとに親の HealthManager に継続ダメージを与える「毒状態」コンポーネント
/// </summary>
[DisallowMultipleComponent] // 同じオブジェクトに複数付けるのを防ぐ
public class PoisonEffect : MonoBehaviour
{
    [Header("毒の基本パラメータ")]
    [Tooltip("毒ダメージを与える対象。未指定の場合は親階層から自動検索します")]
    [SerializeField] private HealthManager targetHealth;

    [Tooltip("1回あたりの毒ダメージ量")]
    [SerializeField] private float damagePerTick = 5f;

    [Tooltip("ダメージを与える間隔(秒)")]
    [SerializeField] private float tickInterval = 1f;

    [Tooltip("毒が続く合計時間(秒)")]
    [SerializeField] private float duration = 5f;

    [Header("挙動設定")]
    [Tooltip("オブジェクト有効化時(OnEnable)に自動で毒を開始するか")]
    [SerializeField] private bool autoStartOnEnable = true;

    [Tooltip("毒が終了したときに、このコンポーネントを自動で破棄するか")]
    [SerializeField] private bool destroyOnFinish = true;

    // 内部状態
    private float _elapsed;        // 経過時間
    private float _tickTimer;      // 次のダメージまでのカウントダウン
    private bool _isRunning;       // 毒が有効かどうか

    private void Reset()
    {
        // コンポーネントを追加したときに、親階層から HealthManager を自動取得
        if (targetHealth == null)
        {
            targetHealth = GetComponentInParent<HealthManager>();
        }
    }

    private void Awake()
    {
        // Awake 時点でも一応自動取得を試みる
        if (targetHealth == null)
        {
            targetHealth = GetComponentInParent<HealthManager>();
        }
    }

    private void OnEnable()
    {
        if (autoStartOnEnable)
        {
            StartPoison();
        }
    }

    private void Update()
    {
        // 毒が動作していなければ何もしない
        if (!_isRunning)
        {
            return;
        }

        // 対象が存在しない、またはすでに死亡しているなら毒を停止
        if (targetHealth == null || targetHealth.IsDead)
        {
            StopPoison();
            return;
        }

        float deltaTime = Time.deltaTime;

        // 全体の経過時間を進める
        _elapsed += deltaTime;

        // 毒の合計時間を超えたら終了
        if (_elapsed >= duration)
        {
            StopPoison();
            return;
        }

        // 次のダメージまでのタイマーを進める
        _tickTimer -= deltaTime;

        // タイマーが0以下になったらダメージを与える
        if (_tickTimer <= 0f)
        {
            ApplyPoisonDamage();

            // 次のティックまでの時間をリセット
            _tickTimer += tickInterval;
        }
    }

    /// <summary>
    /// 毒状態を開始する
    /// </summary>
    public void StartPoison()
    {
        if (targetHealth == null)
        {
            // 対象がいない場合はログを出して終了
            Debug.LogWarning($"PoisonEffect on {gameObject.name} has no targetHealth assigned.");
            _isRunning = false;
            return;
        }

        // パラメータが不正な場合の保険
        damagePerTick = Mathf.Max(0f, damagePerTick);
        tickInterval = Mathf.Max(0.01f, tickInterval); // 0は危険なので最小値を設定
        duration = Mathf.Max(0.01f, duration);

        _elapsed = 0f;
        _tickTimer = 0f; // すぐに1回目のダメージを与えたいので0スタート
        _isRunning = true;

        Debug.Log($"PoisonEffect started on {targetHealth.gameObject.name} via {gameObject.name}");
    }

    /// <summary>
    /// 毒状態を停止する
    /// </summary>
    public void StopPoison()
    {
        if (!_isRunning)
        {
            return;
        }

        _isRunning = false;
        Debug.Log($"PoisonEffect stopped on {gameObject.name}");

        if (destroyOnFinish)
        {
            // コンポーネントだけを破棄(GameObject本体は残す)
            Destroy(this);
        }
    }

    /// <summary>
    /// 実際に毒ダメージを与える処理
    /// </summary>
    private void ApplyPoisonDamage()
    {
        if (targetHealth == null)
        {
            return;
        }

        // HealthManager にダメージを通知
        targetHealth.ApplyDamage(damagePerTick);

        // デバッグ用ログ(実際のゲームではエフェクト再生などに置き換え)
        Debug.Log($"Poison tick: {damagePerTick} damage to {targetHealth.gameObject.name}");
    }

    /// <summary>
    /// 外部から毒パラメータを上書きして開始したい場合に使えるヘルパー
    /// </summary>
    public void ConfigureAndStart(HealthManager newTarget, float newDamagePerTick, float newTickInterval, float newDuration)
    {
        targetHealth = newTarget;
        damagePerTick = newDamagePerTick;
        tickInterval = newTickInterval;
        duration = newDuration;

        StartPoison();
    }
}

使い方の手順

ここからは、実際にプレイヤーや敵に毒状態を付与する手順を見ていきます。

手順①:HPを管理したいオブジェクトに HealthManager を追加

  • プレイヤー、敵、動く床など、ダメージを受ける対象の GameObject を選択します。
  • Add Component から HealthManager を追加します。
  • インスペクターで
    • Max Health:例)100
    • Current Health:未設定なら自動で Max と同じ値になります

これで、そのオブジェクトは ApplyDamage / Heal でHPを増減できるようになります。

手順②:同じオブジェクト or 子オブジェクトに PoisonEffect を追加

  • 毒状態を管理したい GameObject を選択します。
    • 基本パターン:プレイヤー本体や敵本体にそのまま追加
    • 分離パターン:プレイヤーの子オブジェクト(「StatusEffects」など)を作ってそこに追加
  • Add Component から PoisonEffect を追加します。

同じ階層か親階層に HealthManager が存在する場合Reset()Awake() で自動的に targetHealth を探します。
見つからなかった場合は、インスペクターの Target Health に手動でドラッグ&ドロップしてください。

手順③:毒のパラメータを設定

PoisonEffect のインスペクターで、以下を調整します。

  • Damage Per Tick:1回あたりのダメージ量(例:5)
  • Tick Interval:ダメージ間隔(秒)(例:1秒ごと)
  • Duration:毒が続く合計時間(秒)(例:5秒)
  • Auto Start On Enable
    • ON:オブジェクトが有効化された瞬間に毒開始
    • OFF:外部スクリプトから StartPoison() を呼ぶまで待機
  • Destroy On Finish
    • ON:毒終了時に PoisonEffect コンポーネントを自動削除
    • OFF:コンポーネントは残り、StartPoison() を再度呼べる

手順④:具体的な使用例

例1:敵がプレイヤーに毒を付与する

プレイヤー側:

  • Player GameObject に HealthManager を追加

敵側:

  • Enemy GameObject に、プレイヤー接触時に毒を付与するスクリプトを追加します。

using UnityEngine;

/// <summary>プレイヤー接触時に毒を付与する例</summary>
public class EnemyPoisonAttacker : MonoBehaviour
{
    [SerializeField] private float poisonDamagePerTick = 3f;
    [SerializeField] private float poisonInterval = 1f;
    [SerializeField] private float poisonDuration = 6f;

    private void OnTriggerEnter(Collider other)
    {
        // Player タグのオブジェクトに触れたら毒を付与する例
        if (!other.CompareTag("Player")) return;

        // プレイヤーの HealthManager を取得
        HealthManager targetHealth = other.GetComponentInParent<HealthManager>();
        if (targetHealth == null) return;

        // すでに PoisonEffect が付いているか確認
        PoisonEffect poison = other.GetComponentInChildren<PoisonEffect>();
        if (poison == null)
        {
            // なければプレイヤーに PoisonEffect を動的に追加
            poison = other.gameObject.AddComponent<PoisonEffect>();
        }

        // パラメータを設定して毒を開始
        poison.ConfigureAndStart(
            targetHealth,
            poisonDamagePerTick,
            poisonInterval,
            poisonDuration
        );
    }
}

このように、敵は「毒を付与する」責務だけを持ち、実際のHP減少ロジックは HealthManagerPoisonEffect に任せられます。

例2:毒の床(トラップ)に乗ると毒状態になる
  • PoisonFloor という GameObject(BoxCollider + isTrigger = true)を作成。
  • 以下のスクリプトを追加します。

using UnityEngine;

/// <summary>上に乗ったオブジェクトに毒を付与する床</summary>
public class PoisonFloor : MonoBehaviour
{
    [SerializeField] private float poisonDamagePerTick = 2f;
    [SerializeField] private float poisonInterval = 0.5f;
    [SerializeField] private float poisonDuration = 4f;

    private void OnTriggerEnter(Collider other)
    {
        HealthManager targetHealth = other.GetComponentInParent<HealthManager>();
        if (targetHealth == null) return;

        PoisonEffect poison = other.GetComponentInChildren<PoisonEffect>();
        if (poison == null)
        {
            poison = other.gameObject.AddComponent<PoisonEffect>();
        }

        poison.ConfigureAndStart(
            targetHealth,
            poisonDamagePerTick,
            poisonInterval,
            poisonDuration
        );
    }
}

プレイヤーでも敵でも、HealthManager を持っていれば同じ床で毒状態にできます。
「毒の沼」「毒の霧」など、レベルデザイン側で簡単にバリエーションを増やせるのがポイントですね。


メリットと応用

メリット①:責務が分離されていて読みやすい

  • HealthManager は「HPの増減と死亡判定」だけを見る
  • PoisonEffect は「一定間隔でダメージを通知する」だけを見る

このように責務を分けておくと、毒の仕様変更(ダメージ量や間隔、重複ルールなど)を行うときも、PoisonEffect だけ読めばよくなります。
巨大な PlayerController の中から毒の処理を探す……といった地獄から解放されます。

メリット②:プレハブ化・レベルデザインが楽になる

  • 「毒を持つ敵」プレハブ:「敵本体 + HealthManager + PoisonEffect」
  • 「毒の床」プレハブ:「床オブジェクト + PoisonFloor」

このように、コンポーネントの組み合わせだけで挙動を作れるので、レベルデザイナーや他の開発者も扱いやすくなります。
毒のパラメータ(ダメージ量・間隔・継続時間)もインスペクターで調整できるので、ゲームバランス調整も楽ですね。

メリット③:状態異常の追加がしやすい

毒がきれいにコンポーネントとして分離されていれば、

  • BurnEffect(炎上)
  • SlowEffect(移動速度低下)
  • StunEffect(行動不能)

といった別の状態異常も、同じパターンでどんどん増やしていけます。
大事なのは、「1つのコンポーネントは1つの役割に集中させる」という考え方ですね。


改造案:毒の残り時間を取得するメソッドを追加する

UIに「毒:残り3.2秒」と表示したくなる場面も多いので、PoisonEffect残り時間を返す関数を追加してみましょう。


    /// <summary>
    /// 毒の残り時間(秒)を取得する
    /// 動作していない場合は 0 を返す
    /// </summary>
    public float GetRemainingTime()
    {
        if (!_isRunning)
        {
            return 0f;
        }

        return Mathf.Max(0f, duration - _elapsed);
    }

この関数を使えば、別の UI 用コンポーネントから

  • poisonEffect.GetRemainingTime() を呼んでスライダーやテキストに反映

といった形で、表示ロジックもきれいに分離できます。
このように、必要になったタイミングで小さなメソッドを追加していくと、コンポーネント指向らしい拡張のしやすさを実感できるはずです。