【Unity】体力・ダメージ・無敵時間:「攻撃が当たる」をゲームとして完成させる

作成: 2026-07-16

攻撃判定・体力・無敵時間は、ほぼすべてのアクション系ゲームで最初に必要になる基礎システムなのに、「1振りで3回ダメージが入る」「死亡処理が2回走る」と壊れやすい難所でもあります。Health・Hitbox・Hurtboxの役割分担、攻撃アニメに合わせた判定窓、同じ攻撃を1対象1回に束ねる管理、無敵時間とダメージ点滅の分離まで、プレイヤー対敵のアリーナを題材に完成させます。

プレイヤーの移動ができて、敵も配置できた。剣を振るアニメーションも入れた。さて——「攻撃が当たったら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

Sponsored

よくある症状: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(食らい判定)に触れると、Healthへダメージが渡り、HealthがUIや演出へイベントで通知する流れ
役割担当持ち主
Hitbox(攻撃判定)「今、攻撃が当たる範囲」。ダメージ量を知っている剣・弾・敵の体当たり
Hurtbox(食らい判定)「攻撃を受け付ける範囲」。Colliderそのものプレイヤー・敵の体
Health(体力)HPの増減・無敵・死亡の ルールすべてプレイヤー・敵・壊れる物

流れはいつも一方向です。 HitboxがHurtboxに触れる → 相手のHealthへダメージを渡す → Healthがルールで処理し、結果をイベントで外へ知らせる。UIや効果音はHealthのイベントを聞くだけで、ダメージ計算には一切関わりません。

tips: 「ダメージを受けられるものを、種類を問わず同じ入口で殴る」ための IDamageable interfaceは、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が守ります。

Sponsored

攻撃は「判定窓」の間だけ

次は攻撃側です。剣のColliderを出しっぱなしにせず、 攻撃モーションの「当たるべき瞬間」だけ判定を開く——これを判定窓と呼びます。

攻撃の判定窓のタイムライン図。振りかぶり(予備動作)の間は判定オフ、振り抜きの一瞬だけHitboxがオンになり、その後の硬直では再びオフになる

格闘ゲームの「発生前・持続・硬直」と同じ考え方です。判定窓には2つの効果があります。

  • 見た目と判定が一致する: 振りかぶり中に敵が触れてもダメージが入らない。「まだ振ってないのに当たった」という理不尽が消えます
  • 多段ヒットの温床を断つ: 判定が開いている時間が短いほど、触れ直しによる多重ヒットの機会も減ります

実装は「普段はColliderを無効にしておき、攻撃時にコルーチンで開けて閉じる」だけです(次の節のコードに含めます)。本格的にアニメーションと同期させたくなったら、Animation Eventでモーションのフレームに合わせて窓を開閉する のが次のステップです。

同じ攻撃は、1対象に1回だけ

最後のルールが「回数の管理」です。多段ヒットの2つの原因に、それぞれ対応する道具を当てます。

1振り1回の管理図。剣の一振りの攻撃範囲に、複数のColliderを持つ敵がいるが、GetComponentInParentで本体のHealthに束ねられ、HashSetに記録されて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のイベントでつなぎます。

無敵時間の分担図。Healthは無敵タイマーでダメージを無視するルールを担当し、OnDamagedイベントを受けたDamageFlashが無敵の間だけ体を点滅させる見た目を担当する
// 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の近接戦、ローグライクの接触ダメージ——どれもこの構成の変形です。

アリーナの場面図。プレイヤーの剣の判定窓が敵に当たってHPバーが減り敵が点滅する一方、敵の体当たりに触れたプレイヤー側も無敵の点滅で守られている

構成はこうです。

  • プレイヤー: HealthDamageFlash。子オブジェクトの剣に SwordHitbox(IsTriggerのCollider付き)
  • : HealthDamageFlash。体の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行だけのはずです。最後にその敵をもう一度殴る——何も起きなければ、デッドフラグまで全部が機能しています。もしどれかが破れたら、見るべき場所は決まっています。多段ヒットなら GetComponentInParentHashSet、マイナス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回なら、このシステムはもう完成しています。