Skip to content

第04回 衝突判定とダメージ

前回: 第03回 攻撃入力と攻撃モーション / 次回: 第05回 Enemyの探索・追跡・攻撃

今回の授業内容

  • Triggerによる接触イベントの発生条件を理解する
  • 入力、攻撃時間、命中判定、ダメージ解決、HP管理を分けて実装する
  • 攻撃中だけTrigger Colliderを有効にする
  • 同じ攻撃が同じ相手へ複数回当たらないようにする
  • 攻撃からHP減少までの処理の流れを確認する
  • HPゲージへダメージ結果を反映する

今回の配布コード

第03回までの移動、カメラ、攻撃モーションを含み、第04回終了時点の全スクリプトを収録しています。基本実装に必要なファイルと、攻撃演出用の発展ファイルを同じ回のフォルダから確認できます。HealthGauge.csはDOTweenを使用するため、この回の「DOTweenを導入する」を先に完了してください。

ファイル内容
PlayerController.cs移動、カメラ、攻撃の入力
PlayerMove.csプレイヤーの移動と振り向き
CameraMove.csカメラの追従と回転
Health.csHPの保持、ダメージ、回復、死亡判定
DamageReceiver.csダメージを受け取る共通コンポーネント
AttackContext.cs攻撃者とダメージ量の情報
AttackResolver.cs攻撃ルールの判定とダメージ適用
CharacterAttack.cs攻撃時間と命中判定の管理
Player.csPlayerのHPと表示の接続
Enemy.csEnemyのHPと死亡処理
AttackEffect.cs剣を表示して振る攻撃演出
SlashEffect.cs剣閃エフェクトの制御
HealthGauge.csHPゲージの表示

プレイヤーの攻撃と敵のHP減少

Playerの攻撃入力からEnemyのHP減少までを、次の流れで実装します。

攻撃入力からHP減少までの流れ
Player
攻撃ボタンの入力を伝える
Player Attack
Player Attack
攻撃中のColliderで接触を検出する
Enemy
Enemy
ダメージを受け取る
DamageReceiver
DamageReceiver
HPを減らす
Health

攻撃とHP管理を責務ごとに分ける

攻撃処理をCharacterAttackと名付ける

この時点ではPlayerからだけ攻撃を呼び出しますが、攻撃時間、攻撃Collider、命中済み対象、ダメージ解決はPlayer入力そのものには依存しません。次回はEnemyも同じ仕組みで攻撃するため、Player専用のPlayerAttackではなく、最初からCharacterAttackという共通名で実装します。

共通の攻撃処理を利用するコンポーネント
PlayerController
第04回で接続する
CharacterAttack
EnemyController
第05回で接続する
CharacterAttack
CharacterAttack
命中後のルールを任せる
AttackResolver

第04回ではPlayerControllerだけを接続し、第05回でEnemyControllerからも利用します。将来の利用先を見越して名前を決めますが、この回でEnemyの追跡や攻撃判断まで先に実装する必要はありません。

命中後のルールを別の部品へ任せる

例えば小規模なゲームなら、CharacterAttackOnTriggerEnter でEnemyの Health を直接減らす実装でも問題はありません。ですが一定規模以上のゲームでは、開発が進むにつれて CharacterAttack にゲームのルールや特殊な条件判定を書き連ねていくことになりかねません。

壊せるObject、無敵状態、防御力、属性などの仕様が増えるたび、CharacterAttack の中に分岐が増え続けるのは問題です。攻撃命中の処理と、その後何が起きるかの処理は分けるべきでしょう。

保守性を確保するためには、例えば CharacterAttack でダメージ計算まで扱うのではなく、ゲームの仕様に関する処理は AttackResolver に任せるような構成が考えられます。どのコンポーネントが何を扱い、それ以外を扱わないかを徹底することで、コードの可読性とメンテナンス性を高めることができます。

攻撃とHP管理の責務
  • PlayerController
    • 入力を扱い、攻撃の指示を出す
  • CharacterAttack
    • 攻撃の有効時間やタイミング、
    • 攻撃判定のColliderを管理する
  • AttackResolver
    • 攻撃を評価して適切に振り分ける
  • DamageReceiver
    • ダメージを受ける処理を直接担当
  • Health
    • HPの値とその増減を扱う

Colliderでゲーム中の接触を検出する

  • 攻撃範囲に敵が入った
  • 弾がキャラクターに命中した
  • アイテムに触れて取得した
  • ダメージ床に入ってHPが減った

これらの動作はすべて接触をきっかけに起こります。ゲームでインタラクションを作るためには、なんらかの当たり範囲同士が重なったときに検知する仕組みが必要になります。Unityでは Collider と物理エンジンを使って接触を検出できます。

物体の当たり範囲を表すCollider

Collider は、GameObjectの当たり範囲を表すComponentです。Box ColliderSphere ColliderCapsule Collider など、用途に応じた形状のColliderを使い分けます。

ゲームでは、当たり判定の形状や範囲を見た目の形状と完全に一致させる必要はありません。人間の感覚と幾何学的な形状は必ずしも一致しないため、当たり判定は見た目よりも操作感を優先して設定するのが基本となります。多くの場合、プレイ感を優先してシンプルな形状の Collider が使用されます。

CollisionとTriggerを使い分ける

通常のColliderは、物理エンジンによって衝突が発生し、オブジェクト同士が押し合うような反応をします。攻撃判定やアイテム取得のように、衝突は検出したいが物理的な反応は不要な場合は、Colliderの Is Trigger を有効にしてTriggerとして扱います。

種類物理的な反応主な用途
Collisionオブジェクト同士が押し合う壁、床、障害物
Trigger通過を許し、重なりを検出する攻撃範囲、アイテム、ダメージ床

今回、攻撃判定ではTriggerを使って、攻撃範囲に敵が入った瞬間を検出します。

TriggerイベントにはRigidbodyが必要

Collider同士が重なるだけでは Triggerイベントは発生しません。接触はUnityの物理エンジンが判定するため、少なくとも片方のObjectに Rigidbody が必要です。今回はPlayer側に Rigidbody を設定します。Playerはスクリプトで移動させるため Is Kinematic を有効にします。

Player AttackのHierarchy
  • Player
    • Rigidbody
    • Collider
    • Player Attack
      • Collider(Is Trigger)

OnTriggerEnterでコライダーの接触を得る

OnTriggerEnter は、別のColliderがTriggerに入った瞬間にUnityから呼ばれるコールバック関数です。引数の other には Triggerに入ってきたColliderが入ります。

cs
// 他のコライダーがTriggerに入った瞬間に呼ばれる
private void OnTriggerEnter(Collider other)
{
    Debug.Log(other.name);
}

範囲内にいるコライダーを捕捉し続ける場合は OnTriggerStay を。コライダーが範囲から出た瞬間を扱う場合は OnTriggerExit を使います。

PlayerとEnemyのオブジェクト構成

Player と Enemy の双方に DamageReceiver を設定して、ダメージの受け取りを可能にします。Player の攻撃判定用の CharacterAttack は、Player本体とは別の子Objectとして作成し、攻撃中だけ有効になるTrigger Colliderを持たせます。

PlayerとEnemyのHierarchy
  • Player
    • Rigidbody
    • Collider
    • PlayerController
    • PlayerMove
    • DamageReceiver
    • Player Attack
      • Attack Collider(Is Trigger)
  • Enemy
    • Collider
    • DamageReceiver
    • Enemy

HP計算をMonoBehaviourから分離する

HPの増減や死亡判定は、Scene上の位置やColliderとは関係ありません。GameObjectへ直接追加する必要がない計算処理は、普通のC#クラスとしてMonoBehaviourから分けることができます。

例えば HP計算を扱う Health クラスはダメージを受けた際の HPの増減や死亡判定を担当しますが、MonoBehaviourを継承する必要はありません。今回の実装はInspector表示に使うSerializeFieldと値の制限に使うMathfには依存しますが、GameObjectのライフサイクルから計算処理を分離できます。

HealthでHPの値を管理する

Health は HP計算を担当する C#クラスで MonoBehaviour を利用しません。こうした単純なデータ管理クラスをComponentから分けることで、HPの増減や死亡判定のロジックをシンプルに保ちます。

cs
// MonoBehaviourから分離したHP計算クラス
[System.Serializable]
public class Health
{
    [SerializeField] private int maxHealth = 10;

    public int CurrentHealth { get; private set; }
    public int MaxHealth => maxHealth;
    public bool IsDead => CurrentHealth <= 0;

    public void Reset()
    {
        CurrentHealth = maxHealth;
    }
}

TakeDamageとHealにHPの変更処理を集中させる

Health は、HPをどの範囲で増減させるかを担当します。0以下のダメージや回復量は無視し、死亡後に追撃が入っても二重に死亡したり生き返ったりしないようブロックします。HP の数値を直接操作させず、TakeDamage()Heal() を通し集約することで、増減のルールを一元管理できます。

cs
public void TakeDamage(int damage)
{
    if (damage <= 0 || IsDead)
        return;

    CurrentHealth =
        Mathf.Max(CurrentHealth - damage, 0);
}

public void Heal(int heal)
{
    if (heal <= 0 || IsDead)
        return;

    CurrentHealth =
        Mathf.Min(CurrentHealth + heal, maxHealth);
}

DamageReceiverからHealthへ計算を委譲する

HPを持たせたいObjectには DamageReceiver を Componentとして追加します。DamageReceiverHPの増減の対象となるかを扱うコンポーネントです。攻撃の対象となるか否かを管理することで、攻撃者は都度相手がEnemyであるかなどのチェックを行わずにダメージを与えられます。

cs
public class DamageReceiver : MonoBehaviour
{
    [SerializeField] private Health health = new();

    public event Action<int, int> Damaged;
    public event Action<int, int> Healed;

    public bool CanReceiveDamage => !health.IsDead;
    public bool IsDead => health.IsDead;
    public int CurrentHealth => health.CurrentHealth;
    public int MaxHealth => health.MaxHealth;

    private void Awake()
    {
        health.Reset();
    }

    public void ReceiveDamage(int damage)
    {
        var previousHealth = CurrentHealth;

        health.TakeDamage(damage);

        if (CurrentHealth == previousHealth)
            return;

        Damaged?.Invoke(
            previousHealth - CurrentHealth,
            MaxHealth);
    }
}

DamageReceiver は、外部からダメージを受け取る処理の共通コンポーネントです。HP計算は Health に任せ、変化があったときだけ Damaged イベントで通知、演出などを起動します。

AttackResolverで攻撃ルールを適用

AttackResolver は、攻撃が命中した際に具体的に何が起きるかのルールをまとめるクラスです。攻撃結果の判断を集めることで、将来の仕様の変更や拡張に対応しやすくなります。

例えば、攻撃の属性や防御力の計算などの仕様が増えた際もAttackResolver に処理を追加するだけで済み、間接攻撃や全体攻撃といった特殊な攻撃ルールの追加も容易になります。

cs
public static class AttackResolver
{
    public static void Resolve(
        AttackContext attackContext,
        DamageReceiver receiver)
    {
        if (receiver.transform ==
              attackContext.Attacker.transform)
            return;

        if (receiver.transform.IsChildOf(
              attackContext.Attacker.transform))
            return;

        if (!receiver.CanReceiveDamage)
            return;

        receiver.ReceiveDamage(attackContext.Damage);
    }
}

AttackContextへ攻撃情報をまとめる

CharacterAttack は、攻撃した自分の情報と与えるダメージ量を AttackContext にまとめて AttackResolver に渡します。後に攻撃の属性や特殊効果などを追加する際も、AttackContext に情報を追加していく形で対応できます。ダメージ処理に必要な情報が集約されるため、仕様の複雑化への防波堤としての役割も期待できます。

cs
public readonly struct AttackContext
{
    public AttackContext(GameObject attacker, int damage)
    {
        Attacker = attacker;
        Damage = damage;
    }

    public GameObject Attacker { get; }
    public int Damage { get; }
}

攻撃者自身を対象から除外する

Player自身に DamageReceiver があると自分の攻撃が自分には当たりかねません。CharacterAttack で除外しても構いませんが、ここでは AttackResolver に対応を任せています。

cs
// 攻撃者と被弾した相手が同じ場合は処理をキャンセルする
if (receiver.transform == attackContext.Attacker.transform ||
    receiver.transform.IsChildOf(attackContext.Attacker.transform))
    return;

設計の選択肢

この場所が適切かは一考の余地があります。ゲームの仕様と照らして、どこでフィルターするのが適切かを都度判断しましょう。

PlayerControllerから攻撃を指示する

PlayerController は入力を読み取り、移動や攻撃の担当Componentへ処理を渡します。ここでは攻撃ボタンが押された瞬間に CharacterAttack.Attack() を呼んでいます。

攻撃の時間管理や当たり判定は CharacterAttack の担当です。攻撃不可能な場合もあるかもしれませんが、その判定も CharacterAttack の仕事になります。

cs
public class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerMove playerMove;
    [SerializeField] private CameraMove cameraMove;
    [SerializeField] private CharacterAttack characterAttack;

    private InputSystem_Actions _inputActions;

    private void Awake()
    {
        _inputActions = new InputSystem_Actions();
    }

    private void Update()
    {
        // 中略

        // 攻撃の発動
        if (_inputActions.Player.Attack.WasPressedThisFrame())
            characterAttack.Attack();
    }
}

Player Attackに攻撃判定を設定する

Player Attack の Inspector 設定

  1. Playerの子Objectとして Player Attack オブジェクトを作ります
  2. Player AttackCharacterAttack コンポーネントを追加します
  3. Capsule Collider などを追加して Is Trigger を有効にします
  4. CharacterAttackattackCollider に コライダーへの参照を設定して準備完了です

CharacterAttackで攻撃時間を管理する

CharacterAttack は、プレイヤーの攻撃に関する処理を担当します。まず、攻撃可能な時間と、攻撃判定が有効な時間を管理します。

一度の攻撃が同じ相手に複数回ヒットしないよう、命中した相手の記録も行います。攻撃のエフェクトの再生リクエストもここで行います。

cs
public class CharacterAttack : MonoBehaviour
{
    [SerializeField] private AttackEffect attackEffect;
    [SerializeField] private Collider attackCollider;
    [SerializeField] private int damage = 1;
    [SerializeField] private float attackDuration = 0.5f;
    [SerializeField] private float hitStartTime = 0.15f;
    [SerializeField] private float hitEndTime = 0.3f;

    private readonly List<DamageReceiver> _hitReceivers = new();
    private bool _isAttacking;
    private bool _isHitActive;
    private float _attackElapsedTime;
}

Attackで攻撃を開始する

cs
public class CharacterAttack : MonoBehaviour
{
    // 中略

    public void Attack()
    {
        attackEffect?.Play();

        _hitReceivers.Clear();
        _isAttacking = true;
        _isHitActive = false;
        _attackElapsedTime = 0f;
    }
}

Attack() は攻撃開始用の関数です。今回は PlayerController から呼ばれています。

この時点ではまだColliderを有効にせず、Update で判定開始タイミングをチェックします。攻撃演出があれば再生し、前回命中した相手の記録を削除するなど攻撃に必要な初期化をまとめて行います。

Updateで判定の開始と終了を制御する

ゲームでの攻撃は、即時有効とは限りません。攻撃判定発生までの遅延時間、攻撃判定の有効時間、次の攻撃までのクールダウン時間などの時間の管理が必要です。これらの管理は、攻撃の開始と終了を制御する CharacterAttack の仕事になります。

cs
public class CharacterAttack : MonoBehaviour
{
    // 中略

    private void Update()
    {
        if (!_isAttacking)
            return;

        _attackElapsedTime += Time.deltaTime;

        if (!_isHitActive && _attackElapsedTime >= hitStartTime)
            StartHit();

        if (_isHitActive && _attackElapsedTime >= hitEndTime)
            EndHit();

        if (_attackElapsedTime >= attackDuration)
            EndAttack();
    }

Collider.enabledで判定を切り替える

attackCollider は攻撃判定用のTrigger Colliderです。攻撃していない時間はColliderが無効なので、ダメージ判定は発生しません。迂遠な処理に見えますが、これで攻撃の演出と実際に当たるタイミングを分けて調整できます。

cs
public class CharacterAttack : MonoBehaviour
{
    // 中略

    private void Awake()
    {
        attackCollider.enabled = false;
    }

    private void StartHit()
    {
        _isHitActive = true;
        attackCollider.enabled = true;
    }

    private void EndHit()
    {
        _isHitActive = false;
        attackCollider.enabled = false;
    }

OnTriggerEnterでDamageReceiverを探す

cs
public class CharacterAttack : MonoBehaviour
{
    // 中略

    private void OnTriggerEnter(Collider other)
    {
        var receiver =
            other.GetComponentInParent<DamageReceiver>();

        if (receiver == null)
            return;

        if (_hitReceivers.Contains(receiver))
            return;

        _hitReceivers.Add(receiver);

        var attackContext =
            new AttackContext(gameObject, damage);

        AttackResolver.Resolve(attackContext, receiver);
    }

OnTriggerEnter は、攻撃判定Colliderに他のコライダーが入った瞬間に呼ばれます。今回はダメージを与える相手か否かを DamageReceiver を持っているかで判定しています。見つかった場合は AttackResolver に渡し、実際にダメージを与えるかどうかの判断を任せます。

命中済みの相手を記録する

cs
private readonly List<DamageReceiver> _hitReceivers = new();

敵が複数のColliderを持つ場合や激しく移動する場合、1回の攻撃で複数回の命中が発生しかねません。そこで1回の攻撃中に命中した相手をListに記録して、すでに記録されている相手には同じ攻撃で再度ダメージを与えないようにします。

発展

実はListでの管理はあまり効率の良いやり方ではありません。他にも、攻撃の開始と終了でColliderを有効にする方法や、攻撃のIDを付与して管理する方法などがあります。より効率的な方法を考えてみてください。

DOTweenを導入する

DOTweenは、値や位置などを時間をかけて変化させるTween処理を簡潔に記述するためのライブラリです。今回はHPゲージの変化と被弾時の揺れに使用します。

  1. Unity Asset Storeで無料版のDOTween (HOTween v2)をマイアセットへ追加します
  2. UnityのPackage Managerを開き、Packages: My AssetsからDOTweenをImportします
  3. Tools > Demigiant > DOTween Utility Panelを開きます
  4. Setup DOTween...を押して初期設定を完了します
  5. ConsoleにDOTween関連のコンパイルエラーがないことを確認します

DOTweenを更新した場合も、Utility PanelからSetup DOTween...を再実行してください。詳しい導入方法はDOTween公式 Get Startedで確認できます。

HealthGaugeでHPを表示する

Enemy の HP ゲージ

HealthGaugeは、DamageReceiverで変化したHPをUIへ反映します。FrontとBackの2枚のImageを使い、DOTweenでゲージの変化と揺れを再生します。

  1. Canvasの子にHPゲージ用のGameObjectを作ります
  2. FrontとBackのImageを作り、Image TypeFilledにします
  3. 親ObjectへHealthGaugeを追加します
  4. frontbackへ対応するImageを割り当てます
  5. Player用とEnemy用のHPゲージを用意します

攻撃確認用のEnemyとPlayerを作る

今回は移動も攻撃もしない、攻撃テスト用の敵を作ります。ダメージの受け取りはDamageReceiverに任せ、EnemyはHPゲージの更新とHPが0になった後の破棄を担当します。

EnemyDamageReceiver.DamagedHealedを購読し、変化量をHealthGaugeへ渡します。配布版のEnemy.csを使用し、InspectorでhealthGaugeを割り当ててください。

今後の拡張に備えてPlayerも追加します。PlayerはPlayer用のHPゲージを更新し、次回から敵の攻撃を受け取れるようにします。配布版のPlayer.csを使用し、InspectorでhealthGaugeを割り当ててください。

攻撃からHP減少までを確認する

  1. プレイヤーの攻撃入力 →
  2. 攻撃の発生と命中範囲の有効化 →
  3. ヒットが判定されたら 攻撃の情報をAttackResolverに渡す →
  4. AttackResolverがダメージを 受け取るべき相手を判断 →
  5. DamageReceiverがHealthへダメージを反映 →
  6. DamagedイベントをPlayerまたはEnemyが受け取り、HealthGaugeを更新

図を準備しています…

Scene上でコンポーネントを接続する

Playerを設定する

  1. Playerに PlayerController を追加します
  2. Playerの子Objectとして Player Attack を作ります
  3. Player AttackCharacterAttack を追加します
  4. Player Attack にColliderを追加します
  5. Player Attack のColliderで Is Trigger を有効にします
  6. CharacterAttackattackColliderPlayer Attack のColliderを割り当てます
  7. PlayerにPlayerDamageReceiverを追加します
  8. Player.healthGaugeにPlayer用のHPゲージを割り当てます

Enemyを設定する

  1. Enemy用のCubeやCapsuleを配置します
  2. EnemyにColliderが付いていることを確認します
  3. Enemyに DamageReceiver を追加します
  4. Enemyに Enemy を追加します
  5. Enemy.healthGaugeにEnemy用のHPゲージを割り当てます
  6. PlayerまたはEnemyのどちらかにRigidbodyがあることを確認します

Enemy本体のColliderをTriggerにする必要はありません。

実習:Playerの攻撃でEnemyへダメージを与える

  1. DOTweenを導入してSetupを完了します
  2. HealthDamageReceiverAttackContextAttackResolverを作ります
  3. HealthGaugeとPlayer用・Enemy用のHPゲージを作ります
  4. CharacterAttackコンポーネントをPlayer Attackに追加します
  5. Enemyコンポーネントを攻撃テスト用Enemyに追加します
  6. PlayerのPlayerControllerからCharacterAttack.Attack()を呼びます
  7. Player AttackのColliderをTriggerにし、attackColliderに割り当てます
  8. PlayerとEnemyへHealthGaugeを割り当てます
  9. 攻撃ボタンを押したときだけEnemyのHPとHPゲージが減ることを確認します
  10. EnemyのHPが0になったら削除されることを確認します

追加課題:攻撃の見た目を追加する

AttackEffectで剣を表示する

Player の攻撃エフェクト

攻撃中だけ剣本体を表示し、transform.localRotation で剣を振る AttackEffect を追加してください。

任意の剣閃エフェクトを再生する機能も追加してみましょう。Materialを操作する SlashEffect を追加して、ビジュアルにもこだわってみましょう。攻撃判定とは関係ない、あくまで演出として作成してみましょう。

動作しない場合のチェックリスト

  • 攻撃ボタンで CharacterAttack.Attack() が呼ばれているか
  • CharacterAttack.attackCollider にColliderが割り当てられているか
  • 攻撃判定Colliderの Is Trigger が有効か
  • 攻撃中に attackCollider.enabledtrue になるか
  • EnemyにColliderが付いているか
  • Enemyに DamageReceiver が付いているか
  • PlayerとEnemyのhealthGaugeが割り当て済みか
  • DOTweenのSetupが完了しているか
  • PlayerまたはEnemyのどちらかにRigidbodyが付いているか
  • Layer Collision Matrixで接触が無効になっていないか

完成条件

  • 攻撃入力をしたときだけ攻撃判定が有効になる
  • 1回の攻撃で同じEnemyのHPが1回だけ減る
  • PlayerとEnemyのHPゲージがHPの変化を反映する
  • HPが0になったEnemyが削除される
  • CharacterAttack、AttackResolver、DamageReceiver、Healthの役割を説明できる

まとめ

  • Playerは入力を受け取り、CharacterAttackへ攻撃開始を指示する
  • CharacterAttackは攻撃中だけTrigger Colliderを有効にする
  • OnTriggerEnterで接触した相手のDamageReceiverを探す
  • AttackResolverは攻撃ルールを判断してダメージを適用する
  • DamageReceiverはダメージを受け取り、HP計算をHealthへ任せる
  • HPゲージはDamageReceiverのイベントから更新する
  • 攻撃演出は基本の命中処理から分離する