Skip to content

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

前回: 第02回 カメラ操作とコンポーネント分割 / 次回: 第04回 攻撃処理と生成

前回の振り返り

入力、移動、カメラを分割

  • PlayerController が入力を受け取り、 PlayerMove が移動と振り向きを担当し、 CameraMove がカメラの追従と回転を担当する形に分割しました
  • ひとつの巨大なスクリプトにまとめるのではなく、 役割ごとにComponentを分けることで、 各処理の責務を明確にしました

今回は、攻撃ボタンを押したときだけ敵にダメージを与える仕組みを作ります

今回のテーマ

接触イベントと ダメージシステム

攻撃判定とHP管理を 役割ごとに分ける

今回作るもの

Playerの攻撃でダメージを与える

  • Player が攻撃ボタンを押すことで攻撃を発生
  • 攻撃中だけ当たり判定を有効にする
  • 当たり判定に入った相手を検出し、 ダメージを通告する
  • ダメージを受け取った相手はHPを減らす
  • これらの処理を、攻撃動作、攻撃判定、 ダメージ解決、HP管理に分けて実装します
text
Player
  攻撃ボタンを押す
  子ObjectのPlayer Attackへ攻撃開始を伝える

Player Attack
  攻撃中だけColliderが有効になる
  Enemyに触れた瞬間を検出する

Enemy
  DamageReceiverがダメージを受け取る
  HealthがHPを減らす

今回の到達目標

攻撃、接触、ダメージ、HP管理を分ける

  • Playerがボタン入力で攻撃を開始できるようにします
  • Playerの子Objectとして、攻撃判定用の Player Attack を作ります
  • 攻撃中だけ Player Attack のTrigger Colliderを有効にします
  • OnTriggerEnter で攻撃が当たった相手を検出します
  • 相手の DamageReceiver にダメージを渡します
  • HP計算は Health に任せます
  • AttackResolver に攻撃結果の解決を集めます

直接的すぎる実装を避ける

PlayerAttackがEnemyのHPを直接減らすのはNG?

  • 例えば、PlayerAttack の OnTriggerEnter でEnemyの Health を 直接減らす実装でも、小規模なゲームなら問題はありません
  • ですが一定規模以上のゲームでは、開発が進むにつれて 攻撃を扱うだけの PlayerAttack にゲームのルールや特殊な条件判定を 書き連ねていくことになりかねません
  • 壊せるObject、無敵状態、防御力、属性などの仕様が増えるたびに PlayerAttackの中に分岐が増え続けるのは問題です 攻撃命中の処理と、その後何が起きるかの処理は分けるべきでしょう

責務ごとに処理を分ける

コンポーネントの責任を最小に

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

PlayerController
  入力を扱い、攻撃の指示を出す

PlayerAttack
  攻撃の有効時間やタイミング、
  攻撃判定のColliderを管理する

AttackResolver
  攻撃を評価して適切に振り分ける

DamageReceiver
  ダメージを受ける処理を直接担当

Health
  HPの値とその増減を扱う

ゲーム中の接触

まずは衝突判定から

  • 攻撃範囲に敵が入った、弾がキャラクターに命中した、 アイテムに触れて取得した、ダメージ床に入ってHPが減った、 これらの動作はすべて接触をきっかけに起こります
  • ゲームでインタラクションを作るためには、 なんらかの当たり範囲同士が重なったときに 検知する仕組みが必要になります
  • Unityでは Collider と物理エンジンを使って接触を検出できます 攻撃の命中判定で利用しましょう

Collider とは

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

  • Collider は、GameObjectの当たり範囲を表すComponentです
  • Box ColliderSphere ColliderCapsule Collider など、 用途に応じた形状のColliderを使い分けます
  • 見た目の形と当たり範囲を完全に一致させる必要はありません 多くの場合、プレイ感を優先してシンプルな形状にします
  • 攻撃判定も、見た目の武器そのものではなく、 調整しやすい単純な形で作ることが多いです

Collision と Trigger

押し合うか、通過して検出するか

  • Collision Colliderを持つObject同士がぶつかり、押し合います 壁、床、障害物などに使います
  • Trigger Object同士は通過しますが、 範囲に入ったことだけを検出します 攻撃範囲、アイテム取得、ダメージ床などに使います

今回、攻撃判定では Is Trigger を有効にします

Triggerイベントの発生条件

少なくとも片方にRigidbodyが必要

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

OnTriggerEnter

Triggerに入った瞬間に呼ばれるコールバック関数

cs
private void OnTriggerEnter(Collider other)
{
    Debug.Log(other.name);
}
  • 別のColliderがTriggerに入った瞬間にUnityから呼ばれます
  • other には Triggerに入ってきたColliderが入ります
  • 範囲内にいる間ずっと処理する場合は OnTriggerStay を、 範囲から出た瞬間を扱う場合は OnTriggerExit を使います

オブジェクト構成

PlayerとEnemyの例

  • Player と Enemy の双方に DamageReceiver を 設定して、ダメージの受け取りを可能にします
  • Player の攻撃判定用の PlayerAttack は Player本体とは別の子Objectとして作ります
text
Player
  Rigidbody
  Collider
  PlayerController
  PlayerMove
  DamageReceiver
  Player Attack
    Attack Collider(Is Trigger)

Enemy
  Collider
  DamageReceiver
  Enemy

Unityに依存しない処理は分離する

MonoBehaviour を使わない C# クラスで実装

  • HPの増減や死亡判定は、Scene上の位置やColliderとは関係ありません Unityの機能を使わなくても成立する処理は、 できるだけ普通のC#クラスとして書くことを心がけましょう
  • 例えば HP計算を扱う Health クラスは ダメージを受けた際の HPの増減や、死亡判定を担当しますが、 MonoBehaviourを継承せず、Unityの機能に依存しない形で作れます
  • 処理をゲームエンジンの機能から切り離せれば、単体でのテストや再利用が 容易になり、コードの内容もシンプルになるはずです

Healthの実装例

HPの値と計算を管理する C# クラス

  • Health は HP計算を担当する C#クラスで MonoBehaviour を利用しません
  • こうした単純なデータ管理クラスは、Unityの 機能に依存しない形で作ることができます
  • Unity の依存から切り離すことで、 HPの増減や死亡判定のロジックをシンプルに 保ち、単体でのテストも行いやすくなります
cs
[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;
    }
}

HealthのHP計算

TakeDamageとHealで値の変更を管理

  • 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を持つMonoBehaviour

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

DamageReceiverのダメージ受付

Healthへ処理を渡す

  • DamageReceiver は、外部からダメージを 受け取る処理の共通コンポーネントです
  • HP計算は Health に任せ、変化があった ときだけ Damaged イベントで通知、 演出などを起動します
cs
public void ReceiveDamage(int damage)
{
    var previousHealth = CurrentHealth;

    health.TakeDamage(damage);

    if (CurrentHealth == previousHealth)
        return;

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

AttackResolver

攻撃ルールを取り廻す 審判的なstatic クラス

  • 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

攻撃の情報をまとめる構造体

  • PlayerAttack は、攻撃した自分の情報と 与えるダメージ量を 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; }
}

攻撃者自身にはダメージを与えない

AttackResolverで攻撃者を除外する

  • Player自身に DamageReceiver があると 自分の攻撃が自分には当たりかねません
  • PlayerAttack で除外しても構いませんが、 ここでは AttackResolver に対応を任せて います
  • この場所が適切かは、一考の余地があります ゲームの仕様と照らして、何処でフィルター するのが適切か、都度判断していきましょう
cs
// 攻撃者と被弾した相手が同じ場合は処理をキャンセルする
if (receiver.transform == attackContext.Attacker.transform ||
    receiver.transform.IsChildOf(attackContext.Attacker.transform))
    return;

PlayerController

入力を受け取って攻撃へ渡す

  • PlayerController は入力を読み取り、移動や 攻撃の担当Componentへ処理を渡します
  • ここでは攻撃ボタンが押された瞬間に PlayerAttack.Attack() を呼んでいます
  • 攻撃の時間管理や当たり判定は PlayerAttack の担当です
  • 攻撃不可能な場合もあるかもしれませんが その判定は PlayerAttack の仕事になります
cs
public class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerMove playerMove;
    [SerializeField] private CameraMove cameraMove;
    [SerializeField] private PlayerAttack playerAttack;

    private InputSystem_Actions _inputActions;

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

    private void Update()
    {
        // 中略

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

Player Attack に Collider を設定

攻撃に命中判定を付与する

Player Attack の Inspector 設定

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

PlayerAttack

攻撃時間と判定時間を管理する

  • PlayerAttack は、プレイヤーの攻撃に関する 処理を担当します
  • まず、攻撃可能な時間と、攻撃判定が有効な 時間を管理します
  • 一度の攻撃が同じ相手に複数回ヒットしない ように記録も行います
  • 攻撃のエフェクトの再生リクエストも ここで行います
cs
public class PlayerAttack : 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;
}

PlayerAttackの攻撃開始

Attackで攻撃、内部情報を初期化

cs
public class PlayerAttack : MonoBehaviour
{
    // 中略

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

        _hitReceivers.Clear();
        _isAttacking = true;
        _isHitActive = false;
        _attackElapsedTime = 0f;
    }
}
  • Attack() は攻撃開始用の関数です 今回は PlayerController から呼ばれています
  • この時点ではまだColliderを有効にせず、 Update で判定開始タイミングをチェックします
  • 攻撃演出があれば再生し、 前回命中した相手の記録を削除するなど 攻撃に必要な初期化をまとめて行います

PlayerAttackの時間管理

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

  • ゲームでの攻撃は、入力と同時に即時有効とは 限りません
  • 次の攻撃までのクールダウン時間、 攻撃判定発生の遅延時間、 攻撃判定の有効時間などの管理が必要です
  • これらの時間管理は、攻撃の開始と終了を 制御する PlayerAttack の仕事になります
cs
public class PlayerAttack : MonoBehaviour
{
    // 中略

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

        _attackElapsedTime += Time.deltaTime;

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

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

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

PlayerAttackの攻撃判定

Collider.enabledで 当たり判定を有効化/無効化

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

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

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

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

PlayerAttackの命中検出

OnTriggerEnterで DamageReceiverを探す

cs
public class PlayerAttack : 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に記録して、 すでに記録されている相手には同じ攻撃で再度ダメージを与えないようにします
  • 他にも、攻撃の開始と終了でColliderを有効にする方法や、 攻撃のIDを付与して管理する方法などがあります
  • ※ 実はListでの管理はあまり効率の良いやり方ではありません 他の方法がないかも考えてみてください

Enemy

最小構成で攻撃の的を作る

  • 今回は移動も攻撃もしない、攻撃テスト用の 敵として仮に実装します
  • ダメージの受け取りは DamageReceiver に 任せ、HPが0になったら削除する処理だけを Enemy クラスで担当します
cs
[RequireComponent(typeof(DamageReceiver))]
public class Enemy : MonoBehaviour
{
    private DamageReceiver _damageReceiver;

    private void Awake()
    {
        _damageReceiver = GetComponent<DamageReceiver>();
    }

    private void Update()
    {
        if (_damageReceiver.IsDead)
            Destroy(gameObject);
    }
}

プレイヤー側も DamageReceiver を持つ

次回以降の拡張に備える

  • 今後の拡張に備えて Player クラスを新設し、 PlayerのHP管理や死亡処理を担当させます
  • 次回から敵から攻撃されるようにするため、 Playerにも DamageReceiver を付けておきます
cs
    [RequireComponent(typeof(DamageReceiver))]
    public class Player : MonoBehaviour
    {
        // プレイヤーの体力や状態を管理する
        private DamageReceiver _damageReceiver;

        // 略

接続の全体像

攻撃からHP減少まで

  1. プレイヤーの攻撃入力 →
  2. 攻撃の発生と命中範囲の有効化 →
  3. ヒットが判定されたら 攻撃の情報をAttackResolverに渡す →
  4. AttackResolverがダメージを 受け取るべき相手を判断 →
  5. DamageReceiverがダメージを反映
text
Player
   ├─ PlayerController(入力)
   └─ Player Attack
        ├─ PlayerAttack(攻撃時間と命中処理)
        └─ Collider(Is Trigger)
             │ OnTriggerEnter

AttackResolver


Enemy
   ├─ DamageReceiver(Healthを保持)
   └─ Enemy(死亡時にDestroy)

Playerに必要な設定

攻撃する側を作る

  1. Playerに PlayerController を追加します
  2. Playerの子Objectとして Player Attack を作ります
  3. Player AttackPlayerAttack を追加します
  4. Player Attack にColliderを追加します
  5. Player Attack のColliderで Is Trigger を有効にします
  6. PlayerAttackattackColliderPlayer Attack のColliderを割り当てます

Enemyに必要な設定

ダメージを受ける側を作る

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

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

よくある確認ポイント

攻撃が当たらないときは設定を見る

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

実習

Playerの攻撃でEnemyにダメージを与える

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

追加課題

見た目とUIを追加する

余裕のある人向け

AttackEffect

剣本体を表示して振る

Player の攻撃エフェクト

  • 攻撃中だけ剣本体を表示し transform.localRotation で剣を振る AttackEffect を追加しましょう
  • 任意の剣閃エフェクトを 再生する機能も追加してみましょう Materialを操作する SlashEffect を 追加してこだわってみましょう
  • 攻撃判定とは関係ない、あくまで演出として 作成してみましょう

HealthGauge

敵の HP を表示する

Enemy の HP ゲージ

  • さらに余裕があれば HP ゲージを追加しましょう
  • DamageReceiverDamaged イベントを 受け取って、HPの減少をUIに反映させます
  • エフェクトの実習で作ったゲージを 流用するのも良いでしょう

今日のポイント

攻撃、接触、HP管理を分けて考える

  • Playerはボタン入力で攻撃を開始します
  • PlayerAttack は攻撃中だけ攻撃判定Colliderを有効にします
  • OnTriggerEnterDamageReceiver を探します
  • AttackResolver は攻撃結果を解決します
  • DamageReceiver はダメージを受ける入口になります
  • HP計算は Health に任せます
  • 見た目やUIは、攻撃判定の基本が動いてから追加します

Health.cs

cs
using UnityEngine;

[System.Serializable]
// 体力の値とダメージ計算を管理する
public class Health
{
    [Tooltip("最大体力")]
    [SerializeField] private int maxHealth = 10;

    // 現在の体力
    public int CurrentHealth { get; private set; }

    // 最大体力
    public int MaxHealth => maxHealth;

    // 体力が0以下なら死亡扱い
    public bool IsDead => CurrentHealth <= 0;

    public void Reset()
    {
        CurrentHealth = maxHealth;
    }
cs
    public void TakeDamage(int damage)
    {
        // 0以下のダメージや死亡後のダメージは無視する
        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.cs (1/2)

cs
using System;
using UnityEngine;

// ダメージを受ける側の入口になるコンポーネント
public class DamageReceiver : MonoBehaviour
{
    [Tooltip("このオブジェクトの体力")]
    [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;
cs
    private void Awake()
    {
        health.Reset();
    }

    // ダメージを受ける
    public void ReceiveDamage(int damage)
    {
        var previousHealth = CurrentHealth;

        // 体力の増減はHealthに任せる
        health.TakeDamage(damage);

        if (CurrentHealth == previousHealth)
            return;

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

DamageReceiver.cs (2/2)

cs
public class DamageReceiver : MonoBehaviour
{
    // 前半は前ページ

    // 回復を受ける
    // Note: 次回以降の予約で今回は使わない
    public void ReceiveHeal(int heal)
    {
        var previousHealth = CurrentHealth;

        // 体力の増減はHealthに任せる
        health.Heal(heal);

        if (CurrentHealth == previousHealth)
            return;

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

AttackContext.cs / AttackResolver.cs

cs
using UnityEngine;

// 1回の攻撃で使う情報をまとめる
public readonly struct AttackContext
{
    public AttackContext(
        GameObject attacker,
        int damage)
    {
        Attacker = attacker;
        Damage = damage;
    }

    // 攻撃した側のGameObject
    public GameObject Attacker { get; }

    // この攻撃で与えるダメージ量
    public int Damage { get; }
}
cs
// 攻撃が命中したあと、
// 実際にダメージを与えるかを判定する
public static class AttackResolver
{
    public static void Resolve(
        AttackContext attackContext,
        DamageReceiver receiver)
    {
        // 自分自身にはダメージを与えない
        if (receiver.transform ==
            attackContext.Attacker.transform ||
            receiver.transform.IsChildOf(
                attackContext.Attacker.transform))
            return;

        // ダメージを受けられない状態なら何もしない
        if (!receiver.CanReceiveDamage)
            return;

        // ToDo: 必要ならここで状況別のダメージ計算
        receiver.ReceiveDamage(attackContext.Damage);
    }
}

PlayerAttack.cs (1/3)

cs
using System.Collections.Generic;
using UnityEngine;

// プレイヤーの攻撃判定を管理する
public class PlayerAttack : MonoBehaviour
{
    [Tooltip("攻撃時に再生する見た目の演出")]
    [SerializeField] private AttackEffect attackEffect;

    [Tooltip("攻撃中だけ有効にする当たり判定")]
    [SerializeField] private Collider attackCollider;

    [Tooltip("攻撃が命中した相手に与えるダメージ")]
    [SerializeField] private int damage = 1;

    [Tooltip("攻撃全体の時間")]
    [SerializeField] private float attackDuration = 0.5f;

    [Tooltip("攻撃開始から当たり判定を有効にするまでの時間")]
    [SerializeField] private float hitStartTime = 0.15f;
cs
    [Tooltip("攻撃開始から当たり判定を無効にするまでの時間")]
    [SerializeField] private float hitEndTime = 0.3f;

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

    private bool _isAttacking;
    private bool _isHitActive;
    private float _attackElapsedTime;

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

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

        _attackElapsedTime += Time.deltaTime;

PlayerAttack.cs (2/3)

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

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

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

    // 攻撃の呼び出し
    public void Attack()
    {
        attackEffect?.Play();

        // 前回命中した相手の記録をクリアしてから攻撃を開始する
        _hitReceivers.Clear();
        _isAttacking = true;
        _isHitActive = false;
        _attackElapsedTime = 0f;
    }
cs
    private void OnTriggerEnter(Collider other)
    {
        var receiver =
            other.GetComponentInParent<DamageReceiver>();

        // 攻撃の命中対象しか扱わない
        if (receiver == null)
            return;

        // 1回の攻撃で同じ相手に何度もダメージを与えない
        if (_hitReceivers.Contains(receiver))
            return;

        // 被弾済みの相手の登録
        _hitReceivers.Add(receiver);

        // 攻撃の効果をAttackResolverに任せて解決
        var attackContext =
            new AttackContext(gameObject, damage);
        AttackResolver.Resolve(attackContext, receiver);
    }

PlayerAttack.cs (3/3)

cs
    private void EndAttack()
    {
        _isAttacking = false;
        EndHit();
    }

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

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

PlayerController.cs

cs
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerMove playerMove;
    [SerializeField] private CameraMove cameraMove;
    [SerializeField] private PlayerAttack playerAttack;

    private InputSystem_Actions _inputActions;

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

    private void OnEnable()
    {
        _inputActions.Player.Enable();
    }

    private void OnDisable()
    {
        _inputActions.Player.Disable();
    }
cs
    private void Update()
    {
        var moveInput =
            _inputActions.Player.Move.ReadValue<Vector2>();
        var lookInput =
            _inputActions.Player.Look.ReadValue<Vector2>();

        // カメラを回す
        cameraMove.Look(lookInput);

        // カメラの向きを基準に移動方向を作る
        var forward = cameraMove.PlanarForward;
        var right = cameraMove.PlanarRight;
        var direction =
            forward * moveInput.y + right * moveInput.x;
        direction = Vector3.ClampMagnitude(direction, 1f);
        // 実際の移動はPlayerMoveに任せる
        playerMove.Move(direction);

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

Enemy.cs

cs
using UnityEngine;

// 攻撃の的になる最小構成の敵
[RequireComponent(typeof(DamageReceiver))]
public class Enemy : MonoBehaviour
{
    [Tooltip("この敵の体力を表示するゲージ")]
    [SerializeField] private HealthGauge healthGauge;

    private DamageReceiver _damageReceiver;

    private void Awake()
    {
        _damageReceiver =
            GetComponent<DamageReceiver>();
    }

    private void OnEnable()
    {
        _damageReceiver =
            GetComponent<DamageReceiver>();
        _damageReceiver.Damaged += OnDamaged;
        _damageReceiver.Healed += OnHealed;
    }
cs
    private void OnDisable()
    {
        _damageReceiver.Damaged -= OnDamaged;
        _damageReceiver.Healed -= OnHealed;
    }

    private void Update()
    {
        if (_damageReceiver.IsDead)
            Destroy(gameObject);
    }

    private void OnDamaged(int damage, int maxHealth)
    {
        healthGauge.TakeDamage((float)damage / maxHealth);
    }

    private void OnHealed(int heal, int maxHealth)
    {
        healthGauge.Heal((float)heal / maxHealth);
    }
}

Player.cs

cs
using UnityEngine;

// プレイヤーの体力や状態を管理する
[RequireComponent(typeof(DamageReceiver))]
public class Player : MonoBehaviour
{
    private DamageReceiver _damageReceiver;

    [Tooltip("プレイヤーの体力を表示するゲージ")]
    [SerializeField] private HealthGauge healthGauge;

    private void Awake()
    {
        _damageReceiver =
            GetComponent<DamageReceiver>();
    }

    private void OnEnable()
    {
        _damageReceiver =
            GetComponent<DamageReceiver>();
        _damageReceiver.Damaged += OnDamaged;
        _damageReceiver.Healed += OnHealed;
    }
cs
    private void OnDisable()
    {
        _damageReceiver.Damaged -= OnDamaged;
        _damageReceiver.Healed -= OnHealed;
    }

    private void OnDamaged(int damage, int maxHealth)
    {
        healthGauge.TakeDamage((float)damage / maxHealth);
    }

    private void OnHealed(int heal, int maxHealth)
    {
        healthGauge.Heal((float)heal / maxHealth);
    }
}

AttackEffect.cs (1/2)

cs
using System.Collections;
using UnityEngine;

// 攻撃中に剣本体を表示して振る
public class AttackEffect : MonoBehaviour
{
    [Tooltip("攻撃演出を再生する時間")]
    [SerializeField] private float duration = 0.2f;

    [Tooltip("攻撃演出を開始するときの角度")]
    [SerializeField] private float angleFrom = 90;

    [Tooltip("攻撃演出を終了するときの角度")]
    [SerializeField] private float angleTo = 270;

    [Tooltip("攻撃中だけ表示する剣本体のRenderer")]
    [SerializeField] private MeshRenderer swordMeshRenderer;

    [Tooltip("任意で再生する剣閃エフェクト")]
    [SerializeField] private SlashEffect slashEffect;
cs
    private Coroutine _playCoroutine;

    public bool IsPlaying => _playCoroutine != null;

    private void Awake()
    {
        SetVisible(false);
    }

    public void Play()
    {
        if (_playCoroutine != null)
            StopCoroutine(_playCoroutine);

        // 連続で攻撃した場合は、
        // 演出を最初から再生し直す
        _playCoroutine = StartCoroutine(PlayCoroutine());
    }

AttackEffect.cs (2/2)

cs
    private IEnumerator PlayCoroutine()
    {
        SetVisible(true);
        slashEffect?.Play(duration);

        var elapsedTime = 0f;

        while (true)
        {
            var progress =
                Mathf.Clamp01(elapsedTime / duration);

            transform.localRotation =
                Quaternion.Euler(
                    0f,
                    Mathf.Lerp(angleFrom, angleTo, progress),
                    0f);
            yield return null;
            // ラスト1フレームを表示するため評価を遅延している
            if(progress >= 1f)
                break;
            elapsedTime += Time.deltaTime;
        }
cs
        SetVisible(false);

        _playCoroutine = null;
    }

    private void SetVisible(bool isVisible)
    {
        swordMeshRenderer.enabled = isVisible;
    }

    private void OnDisable()
    {
        if (_playCoroutine != null)
        {
            StopCoroutine(_playCoroutine);
            _playCoroutine = null;
        }

        SetVisible(false);
    }
}

SlashEffect.cs (1/2)

cs
using System.Collections;
using UnityEngine;

// 剣閃シェーダーを使った発展的な攻撃エフェクト
public class SlashEffect : MonoBehaviour
{
    private static readonly int MagId =
        Shader.PropertyToID("_Mag");

    [Tooltip("剣閃エフェクトの広がり方")]
    [SerializeField] private AnimationCurve magnitudeCurve =
        AnimationCurve.Linear(0f, 0f, 1f, 1f);

    [Tooltip("攻撃中だけ表示する剣閃エフェクトのRenderer")]
    [SerializeField] private MeshRenderer slashMeshRenderer;

    private Material _slashMaterial;
    private Coroutine _playCoroutine;
cs
    private void Awake()
    {
        _slashMaterial = slashMeshRenderer.material;

        SetMagnitude(0f);
        SetVisible(false);
    }

    public void Play(float duration)
    {
        if (_playCoroutine != null)
            StopCoroutine(_playCoroutine);

        _playCoroutine =
            StartCoroutine(PlayCoroutine(duration));
    }

    private IEnumerator PlayCoroutine(float duration)
    {
        SetVisible(true);

        var elapsedTime = 0f;

SlashEffect.cs (2/2)

cs
        while (elapsedTime < duration)
        {
            var progress = elapsedTime / duration;

            SetMagnitude(
                magnitudeCurve.Evaluate(progress));

            elapsedTime += Time.deltaTime;
            yield return null;
        }

        SetMagnitude(magnitudeCurve.Evaluate(1f));
        SetVisible(false);
        SetMagnitude(0f);

        _playCoroutine = null;
    }

    private void SetMagnitude(float value)
    {
        _slashMaterial.SetFloat(MagId, value);
    }
cs
    private void SetVisible(bool isVisible)
    {
        slashMeshRenderer.enabled = isVisible;
    }

    private void OnDisable()
    {
        if (_playCoroutine != null)
        {
            StopCoroutine(_playCoroutine);
            _playCoroutine = null;
        }

        SetVisible(false);
        SetMagnitude(0f);
    }

    private void OnDestroy()
    {
        Destroy(_slashMaterial);
    }
}

HealthGauge.cs

cs
using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(RectTransform))]
public class HealthGauge : MonoBehaviour
{
    [SerializeField] private Image front;
    [SerializeField] private Image back;
    [SerializeField] private float shakeMagnitude = 1f;

    private float _hp = 1f;
    private RectTransform _rectTransform;

    // ダメージを受ける
    public void TakeDamage(float damage)
    {
        _hp = Mathf.Clamp(_hp - damage, 0f, 1f);

        var sequence = DOTween.Sequence();
cs
        sequence.
            Append(front.DOFillAmount(_hp, 0.2f)).
            Join(_rectTransform.DOShakePosition(
                0.5f, 10f * shakeMagnitude, 20)).
            SetDelay(0.1f).
            Append(back.DOFillAmount(_hp, 0.5f));
    }

    // 回復する
    public void Heal(float heal)
    {
        _hp = Mathf.Clamp(_hp + heal, 0f, 1f);

        var sequence = DOTween.Sequence();
        sequence.Append(front.DOFillAmount(_hp, 0.5f));
        sequence.Join(back.DOFillAmount(_hp, 0.5f));
    }

    private void Awake()
    {
        _rectTransform = GetComponent<RectTransform>();
    }
}

おつかれさまでした!

次回予告 第04回 攻撃処理と生成

敵をやまほど わかせてみる