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);
}
}
}
使い方の手順
ここからは、具体的に「毒の沼」「溶岩」「トゲ床」などとして使う手順を見ていきます。
手順①:スクリプトをプロジェクトに追加する
DamageZone.csをScripts/DamageSystem/など任意のフォルダに保存します。SimpleDamageable.csをScripts/DamageSystem/Samples/などに保存します。- Unity に戻ると自動でコンパイルされます。
手順②:ダメージ床(毒の沼・溶岩)オブジェクトを作る
例として「溶岩床」を作ってみます。
- Hierarchy で 右クリック → 3D Object → Cube を作成し、名前を
LavaFloorに変更。 - 床として使いたいサイズに
Transformの Scale を調整します(例:(10, 0.2, 10))。 LavaFloorに Collider(通常はBoxCollider)が付いていることを確認し、Is Trigger にチェックを入れます。- Add Component ボタンから
DamageZoneを追加します。 - インスペクタで以下を設定します:
- Damage Per Tick:1回あたりのダメージ量(例:
5) - Tick Interval:ダメージ間隔(例:
1秒) - Damage On Enter:ゾーンに入った瞬間にもダメージを与えるなら ON
- Target Layer Mask:
Playerレイヤーなど、ダメージ対象としたいレイヤーを選択 - Target Tag:プレイヤーに
Playerタグを付けているならPlayerと入力(空ならタグ無視)
- Damage Per Tick:1回あたりのダメージ量(例:
これで 「ここに入ると毎秒ダメージを発生させる床」 ができました。
手順③:プレイヤーや敵に「ダメージを受けられる」コンポーネントを付ける
例としてプレイヤーに継続ダメージを受けさせてみます。
- プレイヤーの GameObject(例:
Player)を選択します。 - Add Component から
SimpleDamageableを追加します。 - インスペクタで Max Health と Current Health を好みに合わせて設定(例:
Max 100 / Current 100)。 - プレイヤーのレイヤーを
Playerに設定し、タグもPlayerにしておきます。
この状態でゲームを実行し、プレイヤーを LavaFloor の上に移動させると、毎秒ダメージを受けて HP が減っていく はずです。
コンソールには [Player] LavaFloor から 5 ダメージを受けました。残りHP: 95 のようなログが表示されます。
手順④:別の用途(毒の沼、トゲ床、敵のオーラ)にも使い回す
同じ DamageZone コンポーネントを、少し設定を変えていろいろなギミックに使い回せます。
- 毒の沼地
- 見た目を緑っぽい Plane や Mesh に変更
Damage Per Tick = 2、Tick Interval = 0.5など、じわじわ減る設定- 特定の敵だけに効くように、
Target Layer Maskで「Enemy」レイヤーだけにチェック
- トゲ床
- ダメージ間隔を長め(
Tick Interval = 2.0など)にして、1発のダメージを大きく Damage On Enter = trueにして、踏んだ瞬間にガツンとダメージ
- ダメージ間隔を長め(
- 敵のダメージオーラ
- 敵キャラの子オブジェクトに SphereCollider(Is Trigger)を付けて
DamageZoneをアタッチ - 半径を調整して「近づくだけでダメージを受ける危険エリア」を表現
- 敵キャラの子オブジェクトに SphereCollider(Is Trigger)を付けて
このように、「ダメージを発生させるゾーン」という責務を DamageZone に切り出しておくと、プレイヤーのスクリプトには「HP管理」だけを書いておけばよくなります。
結果として、プレイヤー側は「どんなダメージ源から来たかを気にせず HP を減らす」だけのシンプルなコンポーネントに保てます。
メリットと応用
メリット①:プレハブ化でレベルデザインが爆速になる
DamageZone をアタッチしたオブジェクトを プレハブ化 しておけば、レベルデザイナーはシーン上にポンポン配置するだけで「毒の沼」「溶岩」「トゲ床」を増やしていけます。
- 「プレイヤーの Update に if 文を書き足す」必要がない
- 「このステージだけ溶岩のダメージ量を増やしたい」ときも、プレハブを複製して数値を変えるだけ
- ダメージ床のバリエーションを増やしても、プレイヤー側のコードは一切触らなくてよい
コンポーネント単位で責務を分けておくと、ゲームの規模が大きくなったときの保守性・拡張性がかなり変わってきますね。
メリット②:IDamageable で「受ける側」を抽象化
IDamageable インターフェースを挟んでいるので、プレイヤーも敵もオブジェクトも同じ仕組みでダメージを受けられます。
- プレイヤー用の HP 管理コンポーネント
- 敵用の HP / 死亡処理コンポーネント
- 壊れるオブジェクト用の耐久度コンポーネント
など、それぞれが IDamageable を実装していれば、DamageZone 側のコードは一切変更不要です。
「ダメージ源」と「ダメージの受け手」をゆるく結合しておくことで、後から仕様変更が入っても影響範囲が小さく済みます。
メリット③:Update の責務を極小化できる
DamageZone の Update() では「ゾーン内にいる対象に、一定間隔でダメージを飛ばす」だけを行っています。
プレイヤーの 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 を育ててみてください。
