Unityを触り始めた頃は、つい「とりあえず Update に全部書く」実装をしてしまいがちです。
プレイヤーの移動、敵のAI、当たり判定、HP管理…すべてを1つの巨大スクリプトに押し込んでしまうと、次のような問題が出てきます。
- ちょっと仕様を変えたいだけなのに、どこを触ればいいか分からない
- 敵の種類が増えるたびに if 文だらけになっていく
- プレイヤーと敵のダメージ処理が密結合になり、再利用しづらい
そこで今回は、「敵に触れたらプレイヤーにダメージを与える」というよくある処理を、
小さなコンポーネントとして切り出した 「ContactDamage」コンポーネントを用意してみましょう。
敵側は「触れたらダメージを通知する」だけに責務を絞り、実際のHP減算はプレイヤー側のコンポーネントに任せる構成にすると、
コードの見通しと再利用性が一気に上がります。
【Unity】触れたら痛い!「ContactDamage」コンポーネント
ここでは、
- 敵にアタッチする
ContactDamageコンポーネント - ダメージを受ける側のシンプルな例として
Damageableコンポーネント
の2つを用意します。
敵は「ダメージを与える側」、プレイヤー(や味方)は「ダメージを受ける側」として独立させることで、
どのオブジェクト同士が絡んでいても、同じ仕組みで動くようにしていきます。
フルコード:ContactDamage & Damageable
using UnityEngine;
namespace Sample.Combat
{
/// <summary>
/// ダメージを受けられるオブジェクトのシンプルな実装例。
/// プレイヤーや敵など、HPを持つオブジェクトにアタッチして使います。
///
/// 実運用では、ここからさらに
/// ・死亡時の演出
/// ・UIへの反映
/// ・無敵時間
/// などを拡張していくと良いですね。
/// </summary>
public class Damageable : MonoBehaviour
{
[Header("ステータス")]
[SerializeField] private int maxHp = 10; // 最大HP
[SerializeField] private bool destroyOnDeath = true; // HP0で自動的に破壊するか
// 現在HP。インスペクタで確認したいのでSerializeField。
[SerializeField] private int currentHp;
/// <summary>現在HP(読み取り専用)</summary>
public int CurrentHp => currentHp;
/// <summary>最大HP(読み取り専用)</summary>
public int MaxHp => maxHp;
private void Awake()
{
// 開始時にHPを最大値にセット
currentHp = maxHp;
}
/// <summary>
/// ダメージを受ける関数。
/// 正の値を渡すとHPが減少し、0以下になると死亡扱いにします。
/// </summary>
/// <param name="amount">与えるダメージ量(正の値)</param>
/// <param name="source">ダメージ元(敵やトラップのTransformなど)</param>
public void ApplyDamage(int amount, Transform source = null)
{
if (amount <= 0)
{
// 0以下のダメージは無視(バグ防止)
return;
}
// すでにHP0以下なら何もしない
if (currentHp <= 0)
{
return;
}
currentHp -= amount;
currentHp = Mathf.Max(currentHp, 0);
Debug.Log(
$"[{name}] が {amount} ダメージを受けました。残りHP: {currentHp} / {maxHp} (from: {(source ? source.name : "Unknown")})",
this
);
if (currentHp <= 0)
{
OnDeath();
}
}
/// <summary>
/// 死亡時の処理。
/// ここをvirtualにして、継承クラスで演出を追加するのもアリです。
/// </summary>
protected virtual void OnDeath()
{
Debug.Log($"[{name}] は倒れました。", this);
if (destroyOnDeath)
{
Destroy(gameObject);
}
}
}
/// <summary>
/// 「触れただけでダメージを与える」コンポーネント。
/// 敵やトラップなどにアタッチして使います。
///
/// ・Colliderは「IsTriggerをON」にしておくと扱いやすいです。
/// ・Rigidbodyは、2DならRigidbody2D、3DならRigidbodyを利用してください。
/// </summary>
[RequireComponent(typeof(Collider))]
public class ContactDamage : MonoBehaviour
{
[Header("ダメージ設定")]
[SerializeField] private int damageAmount = 1; // 1回の接触で与えるダメージ量
[SerializeField] private float damageInterval = 0.5f; // 同じ対象に再度ダメージを与えるまでのクールタイム(秒)
[Header("対象のフィルタリング")]
[SerializeField] private LayerMask targetLayerMask; // ダメージ対象にしたいレイヤー(Playerなど)
[SerializeField] private string requiredTag = "Player"; // タグでさらに絞り込みたい場合(空文字なら無視)
[Header("トリガー判定を使うか")]
[SerializeField] private bool useTrigger = true; // true: OnTrigger 系, false: OnCollision 系
// 対象ごとの「最後にダメージを与えた時間」を記録する辞書
private readonly System.Collections.Generic.Dictionary<Damageable, float> _lastDamageTime
= new System.Collections.Generic.Dictionary<Damageable, float>();
private void Reset()
{
// デフォルトではプレイヤーレイヤーを狙う想定で設定
// "Player" レイヤーが存在しない場合は0のままでもOKです。
int playerLayer = LayerMask.NameToLayer("Player");
if (playerLayer >= 0)
{
targetLayerMask = 1 << playerLayer;
}
// タグも"Player"を想定
requiredTag = "Player";
// Colliderをトリガーにしておく
var col = GetComponent<Collider>();
if (col != null)
{
col.isTrigger = true;
}
}
#region Trigger / Collision イベント
private void OnTriggerEnter(Collider other)
{
if (!useTrigger) return;
TryApplyContactDamage(other.gameObject);
}
private void OnTriggerStay(Collider other)
{
if (!useTrigger) return;
TryApplyContactDamage(other.gameObject);
}
private void OnCollisionEnter(Collision collision)
{
if (useTrigger) return;
TryApplyContactDamage(collision.gameObject);
}
private void OnCollisionStay(Collision collision)
{
if (useTrigger) return;
TryApplyContactDamage(collision.gameObject);
}
#endregion
/// <summary>
/// 実際に「このGameObjectにダメージを与えるべきか?」を判定し、
/// 条件を満たすならDamageableにダメージを通知します。
/// </summary>
/// <param name="otherObject">接触した相手のGameObject</param>
private void TryApplyContactDamage(GameObject otherObject)
{
// 1. レイヤーマスクでフィルタリング
if (!IsInTargetLayer(otherObject.layer))
{
return;
}
// 2. タグでさらにフィルタリング(requiredTagが空ならスキップ)
if (!string.IsNullOrEmpty(requiredTag) && !otherObject.CompareTag(requiredTag))
{
return;
}
// 3. Damageableコンポーネントを探す
var damageable = otherObject.GetComponent<Damageable>();
if (damageable == null)
{
// ダメージを受けられない相手なら何もしない
return;
}
// 4. クールタイムチェック
if (!CanDamageNow(damageable))
{
return;
}
// 5. ダメージ適用
damageable.ApplyDamage(damageAmount, transform);
// 6. 最終ダメージ時刻を記録
_lastDamageTime[damageable] = Time.time;
}
/// <summary>
/// 指定レイヤーが targetLayerMask に含まれているか判定します。
/// </summary>
private bool IsInTargetLayer(int layer)
{
// targetLayerMask が 0 の場合は「全レイヤー許可」として扱う
if (targetLayerMask == 0)
{
return true;
}
int layerBit = 1 << layer;
return (targetLayerMask.value & layerBit) != 0;
}
/// <summary>
/// damageInterval に基づき、今ダメージを与えてよいかどうかを判定します。
/// </summary>
private bool CanDamageNow(Damageable damageable)
{
if (damageInterval <= 0f)
{
// インターバル0以下なら「毎フレームでもOK」という扱い
return true;
}
if (!_lastDamageTime.TryGetValue(damageable, out float lastTime))
{
// まだ一度もダメージを与えていない
return true;
}
return (Time.time - lastTime) >= damageInterval;
}
/// <summary>
/// デバッグ用:シーンビュー上で当たり判定の範囲を確認したいときに便利です。
/// </summary>
private void OnDrawGizmosSelected()
{
var col = GetComponent<Collider>();
if (col == null) return;
Gizmos.color = new Color(1f, 0f, 0f, 0.25f);
// 代表的なColliderの種類ごとに描画を分ける
if (col is BoxCollider box)
{
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(box.center, box.size);
}
else if (col is SphereCollider sphere)
{
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawSphere(sphere.center, sphere.radius);
}
else
{
// その他のColliderは位置だけマーカーを出す
Gizmos.DrawSphere(col.bounds.center, 0.1f);
}
}
}
}
使い方の手順
ここからは、プレイヤーが敵に触れたらダメージを受けるという典型的な例で使い方を見ていきます。
-
プレイヤー側に Damageable を付ける
- プレイヤーの GameObject(例:
Player)を選択。 Add ComponentからDamageableを追加。Max Hpを 10 や 20 など好きな値に設定。- プレイヤーに
Tag = Player、レイヤーもPlayerなど分かりやすい名前に設定しておきます。
- プレイヤーの GameObject(例:
-
敵に ContactDamage を付ける
- 敵の GameObject(例:
EnemySlime)を選択。 - すでに
Collider(BoxColliderやSphereColliderなど)が付いていなければ追加。 - 今回のサンプルではトリガー判定を使うので、
Is Triggerにチェックを入れます。 Add ComponentからContactDamageを追加。- インスペクタで以下を設定します:
- Damage Amount: 1〜5 など、触れたときに与えたいダメージ量
- Damage Interval: 0.5〜1.0 秒程度にすると、めり込んだときの多段ヒットを防ぎやすいです
- Target Layer Mask:
Playerレイヤーを選択(または全レイヤーでも可) - Required Tag:
Player(プレイヤーだけがダメージ対象になる) - Use Trigger: トリガー判定を使う場合は ON のまま
- 敵の GameObject(例:
-
物理設定を整える
- プレイヤーには
Rigidbody(またはRigidbody2D)とColliderを付けておきます。 - 敵にも
Colliderを付け、必要に応じてRigidbodyを追加します。- 「動かないトラップ」なら
RigidbodyはなくてもOK(ただし物理設定によっては必要になる場合もあります)。 - 「動く敵」で物理挙動を使うなら
Rigidbodyを付けておきましょう。
- 「動かないトラップ」なら
- 2Dゲームの場合は
Collider2D/Rigidbody2D版に書き換えるか、別スクリプトとして用意すると良いです。
- プレイヤーには
-
シーンでテストする
- ゲームを再生し、プレイヤーを操作して敵に接触させます。
- コンソールに
[Player] が 1 ダメージを受けました。残りHP: 9 / 10 (from: EnemySlime)のようなログが出ていれば成功です。
Damage Intervalを 0 にすると、触れている間毎フレームダメージが入るので、違いも確認してみましょう。
この構成にしておくと、
- プレイヤー以外にも、味方NPCやオブジェクトに
Damageableを付けるだけで「触れたらダメージ」を簡単に共有できる - 敵側は
ContactDamageだけを持てばよく、攻撃アニメーションや移動ロジックとは分離される
というメリットがあります。
メリットと応用
ContactDamage をコンポーネントとして切り出すメリットは、単にコードがきれいになるだけではありません。
- プレハブ化がしやすい
敵プレハブにあらかじめContactDamageを仕込んでおけば、シーンにポンポン配置するだけで「触れたら痛い敵」が量産できます。
ダメージ量やクールタイムだけをインスペクタから調整すれば、弱い敵・強い敵のバリエーションも簡単ですね。 - レベルデザインの自由度が上がる
動く床や回転するトゲ床などのトラップにも、そのままContactDamageを付けるだけで機能します。
「このオブジェクトは触れたらダメージ」というルールをオブジェクト単位で完結できるので、
ステージデザイナーがスクリプトの中身を意識せずにレイアウトを組めるようになります。 - 責務が明確でテストしやすい
Damageableは「ダメージを受けてHPを減らすだけ」、
ContactDamageは「接触を検知してDamageableに伝えるだけ」と責務が分離されているので、
どちらか片方だけを差し替えたり、ユニットテストしやすい構造になります。
さらに、改造案として、例えば「接触した瞬間だけノックバックさせる」機能を追加したい場合、
ContactDamage 内に小さなメソッドを1つ追加するだけで済みます。
/// <summary>
/// ダメージを与えた相手をノックバックさせる簡易処理の例。
/// Rigidbodyを持つ相手に対して、接触方向に力を加えます。
/// </summary>
private void ApplyKnockback(Damageable damageable, float force = 5f)
{
var targetRb = damageable.GetComponent<Rigidbody>();
if (targetRb == null) return;
// 敵(自分)から相手へ向かうベクトル
Vector3 dir = (damageable.transform.position - transform.position).normalized;
// 垂直方向のノックバックを抑えたい場合は、Yを0にして水平成分だけにする
dir.y = 0f;
targetRb.AddForce(dir * force, ForceMode.Impulse);
}
あとは TryApplyContactDamage の中で、ダメージ適用後に ApplyKnockback(damageable); を呼ぶだけで、
「触れたらダメージ+ノックバック」という挙動に拡張できます。
このように、小さなコンポーネントを積み重ねていくと、Godクラスに頼らずに柔軟なアクションゲームを組み立てられますね。




