プレイヤーの移動ができて、敵も配置できた。剣を振るアニメーションも入れた。さて——「攻撃が当たったらHPが減る」を書き始めた途端、奇妙なバグの行列が始まります。1振りで3回ダメージが入る。敵に触れた瞬間、HPが滝のように減 る。HPが0になった後も殴れて、死亡処理が2回走る。
これらは全部、 ダメージ処理の「役割分担」と「回数の管理」 を決めていないことから来る定番の症状です。この記事では、Trigger/Collisionの基礎 と C#イベント を土台に、体力・攻撃判定・無敵時間を 小さなアリーナで完成する形 まで組み上げます。
この記事でわかること
- 「1振りで3回ダメージ」が起きる 2つの原因
- Health / Hitbox / Hurtbox の役割分担
- ダメージの入口を1つに絞る Healthコンポーネント(0未満なし・死亡1回)
- 攻撃アニメに合わせて判定を開け閉めする 判定窓
- 同じ攻撃を 1対象1回 に束ねる管理
- 無敵時間(ルール)とダメージ点滅(見た目) の分離
- 実践:プレイヤー対敵のアリーナを完成させる
動作確認環境: Unity 2022.3 LTS / Unity 6
よくある症状:1振りで3回ダメージが入る
まず、なぜ壊れるのかを正確に知りましょう。「多段ヒットバグ」の原因はほぼ2つに絞れます。
- 相手のColliderが複数ある: 敵が頭・胴体・足に別々のColliderを持っていると、剣が通過したとき Colliderの数だけ
OnTriggerEnterが呼ばれます。コードは正しく動いています——「1体の敵」ではなく「3個のCollider」を数えているだけです - 判定が出ている間に触れ直す: 攻撃判定を出しっぱなしにしていると、敵がわずかに動くたびにExit→Enterが起き、1振りの間に何度もダメージが入ります。
OnTriggerStayで書いていれば、物理更新のたび(毎秒50回)減り続けます
つまり必要なのは、 「どの単位で数えるか(Collider単位でなく相手単位)」と「いつ数えるか(攻撃の間に1 回だけ)」 の2つのルールです。この記事全体が、このルールを部品に落とし込む作業になります。
役割を分ける:Health・Hitbox・Hurtbox
ダメージ処理を1つのスクリプトに書くと、攻撃と防御と演出が絡まって必ず破綻します。アクションゲームの定番は、3つの役割に分けることです。

| 役割 | 担当 | 持ち主 |
|---|---|---|
| Hitbox(攻撃判定) | 「今、攻撃が当たる範囲」。ダメージ量を知っている | 剣・弾・敵の体当たり |
| Hurtbox(食らい判定) | 「攻撃を受け付ける範囲」。Colliderそのもの | プレイヤー・敵の体 |
| Health(体力) | HPの増減・無敵・死亡の ルールすべて | プレイヤー・敵・壊れる物 |
流れはいつも一方向です。 HitboxがHurtboxに触れる → 相手のHealthへダメージを渡す → Healthがルールで処理し、結果をイベントで外へ知らせる。UIや効果音はHealthのイベントを聞くだけで、ダメージ計算には一切関わりません。
tips: 「ダメージを受けられるものを、種類を問わず同じ入口で殴る」ための
IDamageableinterfaceは、C#クラス入門の実践 で作りました。この記事のHealthはその発展形で、同じTakeDamageの入口に「回数と無敵のルール」を足していきます。
Healthコンポーネント:ダメージの入口は1つ
最初に作るのは、ルールの本丸であるHealthです。ポイントは、 HPの変更手段を TakeDamage() の1つに絞り、その中に全ルールを集約する ことです。
// Health.cs — プレイヤーにも敵にも同じものを付ける
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 50;
[SerializeField] private float invincibleTime = 0.5f; // 被弾後の無敵秒数
public int Current { get; private set; }
public bool IsInvincible => Time.time < invincibleUntil;
private float invincibleUntil;
private bool isDead;
// 結果は全部イベントで外へ(UI・音・演出はこれを聞く)
public event Action<int, int> OnChanged; // (現在HP, 最大HP)
public event Action OnDamaged; // 被弾の瞬間(演出のトリガー)
public event Action OnDied; // 死亡(必ず1回だけ)
void Awake()
{
Current = maxHealth;
}
public void TakeDamage(int amount)
{
// ルール1: 死亡済みは何も起きない(死体撃ちでイベントが再発火しない)
if (isDead) return;
// ルール2: 無敵中は無視する
if (IsInvincible) return;
// ルール3: HPは0未満にならない
Current = Mathf.Max(0, Current - amount);
invincibleUntil = Time.time + invincibleTime;
OnChanged?.Invoke(Current, maxHealth);
OnDamaged?.Invoke();
// ルール4: 死亡イベントはフラグを立てた1回だけ
if (Current == 0)
{
isDead = true;
OnDied?.Invoke();
}
}
}
このクラスができた時点で、FAQにあった症状の半分——毎フレーム減る(無敵で止まる)、オーバーキルでマイナス(clampで止まる)、死亡処理2回(デッドフラグで止まる)——は 原理的に起きなくなります。攻撃側がどれだけ雑に TakeDamage を連打しても、ルールはHealthが守ります。
攻撃は「判定窓」の間だけ
次は攻撃側です。剣のColliderを出しっぱなしにせず、 攻撃モーションの「当たるべき瞬間」だけ判定を開く——これを判定窓と呼びます。

格闘ゲームの「発生前・持続・硬直」と同じ考え方です。判定窓には2つの効果があります。
- 見た目と判定が一致する: 振りかぶり中に敵が触れてもダメージが入らない。「まだ振ってないのに当たった」という理不尽が消えます
- 多段ヒットの温床を断つ: 判定が開いている時間が短いほど、触れ直しによる多重ヒットの 機会も減ります
実装は「普段はColliderを無効にしておき、攻撃時にコルーチンで開けて閉じる」だけです(次の節のコードに含めます)。本格的にアニメーションと同期させたくなったら、Animation Eventでモーションのフレームに合わせて窓を開閉する のが次のステップです。
同じ攻撃は、1対象に1回だけ
最後のルールが「回数の管理」です。多段ヒットの2つの原因に、それぞれ対応する道具を当てます。

- Collider単位 → 相手単位に束ねる: 触れたColliderから
GetComponentInParent<Health>()で 親の本体のHealth を取ります。頭・胴・足のどこに当たっても、届く先は同じ1つのHealthです - 「この振りで殴った相手」を覚える: 振りの開始時に空にした
HashSet<Health>へ、殴った相手を記録します。既に入っている相手はスキップ——1振り1回が保証されます
// SwordHitbox.cs — 剣の判定窓+1振り1回の管理
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(Collider))]
public class SwordHitbox : MonoBehaviour
{
[SerializeField] private int damage = 10;
[SerializeField] private float activeTime = 0.2f; // 判定窓の長さ
private Collider hitCollider;
private readonly HashSet<Health> hitThisSwing = new HashSet<Health>();
void Awake()
{
hitCollider = GetComponent<Collider>();
hitCollider.isTrigger = true;
hitCollider.enabled = false; // 普段は判定を閉じておく
}
// 攻撃入力を受け取った側(PlayerAttackなど)がこれを呼ぶ
public void Swing()
{
StopAllCoroutines();
StartCoroutine(OpenWindow());
}
IEnumerator OpenWindow()
{
hitThisSwing.Clear(); // この振りの記録をリセット
hitCollider.enabled = true; // 判定窓を開く
yield return new WaitForSeconds(activeTime);
hitCollider.enabled = false; // 閉じる
}
void OnTriggerEnter(Collider other)
{
// どのColliderに当たっても「本体のHealth」1つに束ねる
Health health = other.GetComponentInParent<Health>();
if (health == null) return;
// まだこの振りで殴っていない相手ならtrue
if (hitThisSwing.Add(health))
{
health.TakeDamage(damage);
}
}
}
これで攻撃側も完成です。 窓を開ける(Swing)→ 触れた相手を本体単位で数える → 1振り1回だけ通す。3つのルールがこの60行に収まっています。
無敵時間:ルールと見た目を分ける
Healthに入れた無敵時間は「ルール」です。でもプレイヤーには、 今は無敵だと見た目で伝える 必要があります——定番はスプライトやモデルの点滅です。
大事なのは、 点滅の処理をHealthに書かない ことです。無敵は「ダメージを受けるかどうか」のルール、点滅は「それをどう見せるか」の演出。別のコンポーネントに分けて、Healthのイベントでつなぎます。

// DamageFlash.cs — 見た目担当:無敵の間だけ点滅する
using System.Collections;
using UnityEngine;
public class DamageFlash : MonoBehaviour
{
[SerializeField] private Health health;
[SerializeField] private Renderer bodyRenderer;
[SerializeField] private float flashInterval = 0.1f;
void OnEnable()
{
health.OnDamaged += StartFlash;
}
void OnDisable()
{
health.OnDamaged -= StartFlash; // 購読解除を忘れない
}
void StartFlash()
{
StopAllCoroutines();
StartCoroutine(Flash());
}
IEnumerator Flash()
{
// ルール(無敵時間)が続く間だけ、見た目(点滅)を続ける
while (health.IsInvincible)
{
bodyRenderer.enabled = !bodyRenderer.enabled;
yield return new WaitForSeconds(flashInterval);
}
bodyRenderer.enabled = true; // 最後は必ず表示に戻す
}
}
この分け方なら、後から「点滅ではなく白フラッシュにしたい」「音も足したい」となっても、 Healthのコードには1文字も触りません。イベントを聞く演出係を足すだけです。
実践:アリーナでプレイヤー対敵を完成させる
部品が揃いました。小さなアリーナで「プレイヤーの剣 対 敵の体当たり」を組んで、全ルールが動くところを見届けましょう。メトロイドヴァニアの剣戟、見下ろしARPGの近接戦、ローグライクの接触ダメージ——どれもこの構成の変形です。

構成はこうです。
- プレイヤー:
Health+DamageFlash。子オブジェクトの剣にSwordHitbox(IsTriggerのCollider付き) - 敵:
Health+DamageFlash。体のTrigger Colliderに次の接触ダメージを付ける
// EnemyContactDamage.cs — 敵の体当たり:触れている間ダメージを「試みる」
using UnityEngine;
public class EnemyContactDamage : MonoBehaviour
{
[SerializeField] private int damage = 5;
void OnTriggerStay(Collider other)
{
Health health = other.GetComponentInParent<Health>();
if (health != null)
{
// 毎物理更新で呼ばれるが、無 敵中はHealth側が無視してくれる。
// 「連打の制御」は攻撃側でなくルール側(Health)の仕事
health.TakeDamage(damage);
}
}
}
OnTriggerStay で毎物理更新ダメージを「試みて」いるのに、HPが滝のように減らないことに注目してください。 受け側のHealthが無敵時間で受付間隔を制御している ので、攻撃側は雑でいいのです。接触している限り「無敵が切れるたびに1回」という、アクションゲームらしい挙動が自然に出来上がります。
Playを押したら、壊れやすかった場所を順に突いてみましょう。まず敵に頭と胴の 2つのColliderをわざと付けて 剣を1振り——HPの減りが1回分なら、束ねと記録が仕事をしています。次に敵のHPを1にして攻撃力10の剣で殴る——HPは0で止まり、Consoleの死亡ログは1行だけのはずです。最後にその敵をもう一度殴る——何も起きなければ、デッドフラグまで全部が機能しています。もしどれかが破れたら、見るべき場所は決まっています。多段ヒットなら GetComponentInParent と HashSet、マイナスHPと二重死亡なら TakeDamage 冒頭の2つのガードです。
ポイントは2つ。 ルールは全部Healthの中(攻撃側は「試みる」だけ。だから攻撃の種類が増えても壊れない)。 回数の管理は「相手単位×1振り」(Collider単位で数えた瞬間に多段ヒットが戻ってくる)。
おまけ:先に知っておくと良いこと
- 弾・罠・回復も同じ入口: 飛び道具は「動くHitbox」、罠は「置きっぱなしのHitbox」、回復は
TakeDamageの隣にHeal()を足すだけ ——入口をHealthに絞った設計は、そのまま拡張に耐えます。弾の当たり判定自体は Raycastの記事 が詳しいです - ノックバックは「ルールの例外」を最初に決める: 無敵中でも押し返しだけは受けるのか、完全に無効か。どちらでも作れますが、決めずに書き始めると必ず絡まります
- 敵を大量に出すなら破棄に注意: 死亡した敵を
Destroyではなく オブジェクトプール へ戻す設計にした場合、HPと無敵タイマーのリセット、イベント購読の解除がプール返却時の仕事に加わります
まとめ
- 多段ヒットの原因は Collider単位で数えている か 判定を出しっぱなし のどちらか
- 役割は3つ: Hitbox(攻撃)・Hurtbox(食らい)・Health(ルール)。流れは常に一方向
- Healthの
TakeDamageに全ルールを集約:clamp・無敵・デッドフラグ・イベント1回 - 攻撃は 判定窓 の間だけ。相手は
GetComponentInParentで本体単位に束ね、HashSetで1振り1回 - 無敵はルール、点滅は見た目。イベントでつなぎ、Healthに演出を書かない
あなたのゲームの敵を、いま2つColliderを持つ敵に差し替えたら——ダメージは何回入るでしょうか? 試して1回なら、このシステムはもう完成しています。