Unityを触り始めたころは、ついなんでもかんでも Update() に書いてしまいがちですよね。
プレイヤーの移動、入力、HP管理、当たり判定、エフェクト制御…すべて1つのスクリプトに押し込んでしまうと、少し仕様変更が入っただけでコード全体に影響が出てしまいます。

とくに「毒の沼地」「溶岩」「トゲ床」などの継続ダメージ床を実装するときに、Update() でプレイヤーの位置を監視して、条件分岐でダメージ処理を書く…というのは典型的な「Godクラス」化の入り口です。

そこでこの記事では、「ダメージ床」という概念をきれいにコンポーネントとして切り出した DamageZone コンポーネントを紹介します。
オブジェクト側に「ここは毎秒ダメージを発生させるゾーンですよ」という責務だけを持たせることで、プレイヤーや敵の実装をスリムに保ちつつ、レベルデザインもしやすくしていきましょう。

【Unity】毒の沼や溶岩をコンポーネント化!「DamageZone」コンポーネント

今回作る DamageZone は、

  • 毒の沼地や溶岩など、「入っている間だけ継続ダメージ」を発生させる
  • 毎秒ごとに「ダメージイベント」を発行する
  • プレイヤーでも敵でも、「ダメージを受けられる側」がインターフェースに対応していれば使い回せる

というコンポーネントです。


フルコード:DamageZone とシンプルなダメージ受け取り側

以下は Unity6 / C# 用の、コピペで動く完全なコードです。
1ファイル目にダメージインターフェースとダメージ床、2ファイル目にテスト用の「ダメージを受ける側」のコンポーネントを用意しています。

DamageZone.cs


using System.Collections.Generic;
using UnityEngine;

namespace DamageSystem
{
    /// <summary>
    /// ダメージを受けられるオブジェクトが実装すべきインターフェース
    /// </summary>
    public interface IDamageable
    {
        /// <summary>
        /// ダメージを受けたときに呼ばれる
        /// </summary>
        /// <param name="amount">ダメージ量(正の値)</param>
        /// <param name="source">ダメージの発生源(この場合は DamageZone)</param>
        void TakeDamage(float amount, GameObject source);
    }

    /// <summary>
    /// 継続ダメージ床(毒の沼、溶岩など)を表すコンポーネント
    /// ゾーンに入っている間、一定間隔でダメージを与える
    /// </summary>
    [RequireComponent(typeof(Collider))]
    public class DamageZone : MonoBehaviour
    {
        [Header("ダメージ設定")]
        [SerializeField]
        [Tooltip("1回あたりのダメージ量")]
        private float damagePerTick = 10f;

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

        [SerializeField]
        [Tooltip("ゾーンに入った瞬間にもダメージを与えるか")]
        private bool damageOnEnter = true;

        [Header("対象フィルタリング")]
        [SerializeField]
        [Tooltip("このレイヤーに属するオブジェクトにのみダメージを与える(0 の場合は無視)")]
        private LayerMask targetLayerMask = ~0; // デフォルトは全レイヤー対象

        [SerializeField]
        [Tooltip("タグで対象を絞りたい場合に指定(空文字ならタグ条件は無視)")]
        private string targetTag = "";

        [Header("デバッグ表示")]
        [SerializeField]
        [Tooltip("Gizmosでゾーンを可視化する")]
        private bool drawGizmos = true;

        [SerializeField]
        [Tooltip("Gizmosの色")]
        private Color gizmoColor = new Color(1f, 0.3f, 0f, 0.35f);

        // 現在ゾーン内にいる IDamageable のキャッシュ
        private readonly HashSet<IDamageable> _targetsInZone = new HashSet<IDamageable>();

        // 次にダメージを与えるべき時間
        private float _nextTickTime;

        // 自身のコライダー(IsTrigger 推奨)
        private Collider _collider;

        private void Awake()
        {
            _collider = GetComponent<Collider>();

            // 安全のため、トリガーになっていなかったら警告を出す
            if (!_collider.isTrigger)
            {
                Debug.LogWarning(
                    $"[DamageZone] {name} の Collider は isTrigger = true を推奨します。",
                    this
                );
            }
        }

        private void OnEnable()
        {
            // 有効化されたタイミングでタイマーをリセット
            _nextTickTime = Time.time + tickInterval;
        }

        private void Update()
        {
            // ゾーン内に誰もいなければ何もしない
            if (_targetsInZone.Count == 0)
            {
                return;
            }

            // 次のダメージタイミングに達していなければ何もしない
            if (Time.time < _nextTickTime)
            {
                return;
            }

            // ダメージを与える
            ApplyDamageToAll();

            // 次のタイミングを更新
            _nextTickTime = Time.time + tickInterval;
        }

        /// <summary>
        /// ゾーンに入ったオブジェクトを検知
        /// </summary>
        /// <param name="other">侵入してきた Collider</param>
        private void OnTriggerEnter(Collider other)
        {
            if (!IsTarget(other.gameObject))
            {
                return;
            }

            // IDamageable を探す(同じオブジェクトか親子階層から)
            IDamageable damageable = other.GetComponentInParent<IDamageable>();
            if (damageable == null)
            {
                return;
            }

            // セットに追加
            _targetsInZone.Add(damageable);

            // 入った瞬間にもダメージを与える設定ならすぐに一発
            if (damageOnEnter)
            {
                damageable.TakeDamage(damagePerTick, gameObject);
            }
        }

        /// <summary>
        /// ゾーンから出たオブジェクトを検知
        /// </summary>
        /// <param name="other">退出した Collider</param>
        private void OnTriggerExit(Collider other)
        {
            if (!IsTarget(other.gameObject))
            {
                return;
            }

            IDamageable damageable = other.GetComponentInParent<IDamageable>();
            if (damageable == null)
            {
                return;
            }

            // セットから除外
            _targetsInZone.Remove(damageable);
        }

        /// <summary>
        /// レイヤー・タグ条件を満たしているか判定
        /// </summary>
        private bool IsTarget(GameObject obj)
        {
            // レイヤーマスク判定
            int layerBit = 1 << obj.layer;
            if ((targetLayerMask.value & layerBit) == 0)
            {
                return false;
            }

            // タグ条件(指定されている場合のみチェック)
            if (!string.IsNullOrEmpty(targetTag) && !obj.CompareTag(targetTag))
            {
                return false;
            }

            return true;
        }

        /// <summary>
        /// ゾーン内の全ターゲットにダメージを適用
        /// </summary>
        private void ApplyDamageToAll()
        {
            // HashSet を直接 foreach している間に中身が変わるとエラーになるため、
            // 一旦バッファにコピーしてから回します。
            var buffer = ListPool<IDamageable>.Get();
            try
            {
                buffer.AddRange(_targetsInZone);

                foreach (var target in buffer)
                {
                    if (target == null)
                    {
                        continue;
                    }

                    target.TakeDamage(damagePerTick, gameObject);
                }
            }
            finally
            {
                ListPool<IDamageable>.Release(buffer);
            }
        }

        /// <summary>
        /// シーンビューでゾーンの範囲を可視化
        /// </summary>
        private void OnDrawGizmos()
        {
            if (!drawGizmos)
            {
                return;
            }

            var col = GetComponent<Collider>();
            if (col == null)
            {
                return;
            }

            Gizmos.color = gizmoColor;

            // BoxCollider / SphereCollider / CapsuleCollider にざっくり対応
            if (col is BoxCollider box)
            {
                Matrix4x4 oldMatrix = Gizmos.matrix;
                Gizmos.matrix = transform.localToWorldMatrix;
                Gizmos.DrawCube(box.center, box.size);
                Gizmos.matrix = oldMatrix;
            }
            else if (col is SphereCollider sphere)
            {
                Gizmos.DrawSphere(
                    sphere.bounds.center,
                    sphere.radius * Mathf.Max(transform.lossyScale.x, transform.lossyScale.y, transform.lossyScale.z)
                );
            }
            else if (col is CapsuleCollider capsule)
            {
                // カプセルはざっくりと球で表現(詳細な描画は割愛)
                Gizmos.DrawSphere(capsule.bounds.center, Mathf.Max(capsule.radius, capsule.height * 0.5f));
            }
            else
            {
                // その他のコライダーは Bounds で近似
                Gizmos.DrawCube(col.bounds.center, col.bounds.size);
            }
        }
    }

    /// <summary>
    /// 簡易 List プール実装
    /// GC 削減のために DamageZone 内部で使用
    /// </summary>
    internal static class ListPool<T>
    {
        private static readonly Stack<List<T>> Pool = new Stack<List<T>>();

        public static List<T> Get()
        {
            if (Pool.Count > 0)
            {
                return Pool.Pop();
            }

            return new List<T>();
        }

        public static void Release(List<T> list)
        {
            list.Clear();
            Pool.Push(list);
        }
    }
}

SimpleDamageable.cs(テスト用:HPを持つオブジェクト)


using UnityEngine;
using DamageSystem;

namespace DamageSystem.Samples
{
    /// <summary>
    /// シンプルなダメージ受け取りコンポーネント
    /// プレイヤーや敵などにアタッチして使う想定
    /// </summary>
    public class SimpleDamageable : MonoBehaviour, IDamageable
    {
        [Header("HP 設定")]
        [SerializeField]
        [Tooltip("最大HP")]
        private float maxHealth = 100f;

        [SerializeField]
        [Tooltip("現在のHP(インスペクタで初期値を調整可)")]
        private float currentHealth = 100f;

        [Header("デバッグ")]
        [SerializeField]
        [Tooltip("ダメージを受けたときにログを出す")]
        private bool logOnDamage = true;

        private bool _isDead;

        private void Reset()
        {
            // コンポーネントを追加したときに自動で初期値を設定
            currentHealth = maxHealth;
        }

        /// <summary>
        /// DamageZone などからダメージを受けたときに呼ばれる
        /// </summary>
        public void TakeDamage(float amount, GameObject source)
        {
            if (_isDead)
            {
                return;
            }

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

            currentHealth -= damage;

            if (logOnDamage)
            {
                Debug.Log(
                    $"[{name}] {source.name} から {damage} ダメージを受けました。残りHP: {currentHealth}",
                    this
                );
            }

            if (currentHealth <= 0f)
            {
                Die();
            }
        }

        /// <summary>
        /// HP が 0 以下になったときの処理
        /// </summary>
        private void Die()
        {
            if (_isDead)
            {
                return;
            }

            _isDead = true;
            currentHealth = 0f;

            Debug.Log($"[{name}] は力尽きました。", this);

            // ここではシンプルにオブジェクトを非アクティブ化
            // 実際のゲームではリスポーン処理やアニメーション再生などを行うと良いです。
            gameObject.SetActive(false);
        }
    }
}

使い方の手順

ここからは、具体的に「毒の沼」「溶岩」「トゲ床」などとして使う手順を見ていきます。

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

  1. DamageZone.csScripts/DamageSystem/ など任意のフォルダに保存します。
  2. SimpleDamageable.csScripts/DamageSystem/Samples/ などに保存します。
  3. Unity に戻ると自動でコンパイルされます。

手順②:ダメージ床(毒の沼・溶岩)オブジェクトを作る

例として「溶岩床」を作ってみます。

  1. Hierarchy で 右クリック → 3D Object → Cube を作成し、名前を LavaFloor に変更。
  2. 床として使いたいサイズに Transform の Scale を調整します(例:(10, 0.2, 10))。
  3. LavaFloorCollider(通常は BoxCollider)が付いていることを確認し、Is Trigger にチェックを入れます。
  4. Add Component ボタンから DamageZone を追加します。
  5. インスペクタで以下を設定します:
    • Damage Per Tick:1回あたりのダメージ量(例:5
    • Tick Interval:ダメージ間隔(例:1 秒)
    • Damage On Enter:ゾーンに入った瞬間にもダメージを与えるなら ON
    • Target Layer MaskPlayer レイヤーなど、ダメージ対象としたいレイヤーを選択
    • Target Tag:プレイヤーに Player タグを付けているなら Player と入力(空ならタグ無視)

これで 「ここに入ると毎秒ダメージを発生させる床」 ができました。

手順③:プレイヤーや敵に「ダメージを受けられる」コンポーネントを付ける

例としてプレイヤーに継続ダメージを受けさせてみます。

  1. プレイヤーの GameObject(例:Player)を選択します。
  2. Add Component から SimpleDamageable を追加します。
  3. インスペクタで Max HealthCurrent Health を好みに合わせて設定(例:Max 100 / Current 100)。
  4. プレイヤーのレイヤーを Player に設定し、タグも Player にしておきます。

この状態でゲームを実行し、プレイヤーを LavaFloor の上に移動させると、毎秒ダメージを受けて HP が減っていく はずです。
コンソールには [Player] LavaFloor から 5 ダメージを受けました。残りHP: 95 のようなログが表示されます。

手順④:別の用途(毒の沼、トゲ床、敵のオーラ)にも使い回す

同じ DamageZone コンポーネントを、少し設定を変えていろいろなギミックに使い回せます。

  • 毒の沼地
    • 見た目を緑っぽい Plane や Mesh に変更
    • Damage Per Tick = 2Tick Interval = 0.5 など、じわじわ減る設定
    • 特定の敵だけに効くように、Target Layer Mask で「Enemy」レイヤーだけにチェック
  • トゲ床
    • ダメージ間隔を長め(Tick Interval = 2.0 など)にして、1発のダメージを大きく
    • Damage On Enter = true にして、踏んだ瞬間にガツンとダメージ
  • 敵のダメージオーラ
    • 敵キャラの子オブジェクトに SphereCollider(Is Trigger)を付けて DamageZone をアタッチ
    • 半径を調整して「近づくだけでダメージを受ける危険エリア」を表現

このように、「ダメージを発生させるゾーン」という責務を DamageZone に切り出しておくと、プレイヤーのスクリプトには「HP管理」だけを書いておけばよくなります。
結果として、プレイヤー側は「どんなダメージ源から来たかを気にせず HP を減らす」だけのシンプルなコンポーネントに保てます。


メリットと応用

メリット①:プレハブ化でレベルデザインが爆速になる

DamageZone をアタッチしたオブジェクトを プレハブ化 しておけば、レベルデザイナーはシーン上にポンポン配置するだけで「毒の沼」「溶岩」「トゲ床」を増やしていけます。

  • 「プレイヤーの Update に if 文を書き足す」必要がない
  • 「このステージだけ溶岩のダメージ量を増やしたい」ときも、プレハブを複製して数値を変えるだけ
  • ダメージ床のバリエーションを増やしても、プレイヤー側のコードは一切触らなくてよい

コンポーネント単位で責務を分けておくと、ゲームの規模が大きくなったときの保守性・拡張性がかなり変わってきますね。

メリット②:IDamageable で「受ける側」を抽象化

IDamageable インターフェースを挟んでいるので、プレイヤーも敵もオブジェクトも同じ仕組みでダメージを受けられます

  • プレイヤー用の HP 管理コンポーネント
  • 敵用の HP / 死亡処理コンポーネント
  • 壊れるオブジェクト用の耐久度コンポーネント

など、それぞれが IDamageable を実装していれば、DamageZone 側のコードは一切変更不要です。
「ダメージ源」と「ダメージの受け手」をゆるく結合しておくことで、後から仕様変更が入っても影響範囲が小さく済みます。

メリット③:Update の責務を極小化できる

DamageZoneUpdate() では「ゾーン内にいる対象に、一定間隔でダメージを飛ばす」だけを行っています。
プレイヤーの Update() では「入力」「移動」「アニメーション制御」など別の責務に集中でき、1コンポーネント1責務 に近づけます。


改造案:ダメージ発生時にエフェクトを再生する

もう一歩踏み込んで、ダメージが発生したときにパーティクルエフェクトを再生するような改造も簡単です。
以下は DamageZone に追加できる、ダメージ発生位置にエフェクトを出すためのサンプルメソッドです。


[SerializeField]
[Tooltip("ダメージ発生時に再生するエフェクト(任意)")]
private ParticleSystem damageEffectPrefab;

/// <summary>
/// 指定した対象の足元あたりにエフェクトを再生する
/// (DamageZone 内の任意の場所に設置してもOK)
/// </summary>
private void SpawnDamageEffect(Transform targetTransform)
{
    if (damageEffectPrefab == null)
    {
        return;
    }

    // 対象の足元あたりを狙って少しだけ下にオフセット
    Vector3 spawnPos = targetTransform.position;
    spawnPos.y += 0.1f;

    ParticleSystem effect = Instantiate(damageEffectPrefab, spawnPos, Quaternion.identity);
    effect.Play();

    // エフェクトの再生が終わったら自動で破棄
    Destroy(effect.gameObject, effect.main.duration + effect.main.startLifetime.constantMax);
}

このメソッドを ApplyDamageToAll() のループ内で、TakeDamage 呼び出しの直後に呼べば、ダメージを受けるたびにパーティクルが出るようになります。
こうした「視覚効果」「サウンド再生」も、さらに別コンポーネントに切り出していくと、よりクリーンな構成に発展させられますね。

ぜひ、自分のプロジェクトに合わせて DamageZone を育ててみてください。