Unityを触り始めた頃って、つい「とりあえず Update() に全部書いてしまう」ことが多いですよね。
プレイヤーの移動、攻撃、ダメージ処理、UI更新……すべてを1つの巨大スクリプトに詰め込むと、次のような問題が出てきます。

  • どこで何が起きているか追えなくなる(デバッグ地獄)
  • 別のキャラや敵にロジックを流用しづらい
  • ちょっと仕様変更するだけで他の機能が壊れがち

とくに「ダメージ判定」まわりは、攻撃側防御側ステータスエフェクトなどが絡み合いやすく、
気づくと1つのクラスが「HP管理+ダメージ計算+回避判定+UI更新」みたいなGodクラスになりがちです。

そこでこの記事では、「回避判定」だけに責務を絞ったコンポーネントとして、
EvasionCalculator を用意して、攻撃を受けたときに Agility(敏捷)ステータスに基づいて確率でダメージを無効化(Miss) する仕組みを作ってみましょう。

【Unity】ステータス連動でスマートに回避判定!「EvasionCalculator」コンポーネント

ここでは、以下のようなシンプルな構成を目指します。

  • Damageable …… 「ダメージを受けられる存在」を表すコンポーネント
  • SimpleStats …… HP と Agility を持つだけの軽量ステータス
  • EvasionCalculator …… Agility に応じて「回避(Miss)するかどうか」を判定するコンポーネント

攻撃が当たったとき、DamageableEvasionCalculator に「今回の攻撃、回避する?」と問い合わせ、
回避成功ならダメージ0、失敗なら普通にダメージ適用、という流れにします。

フルコード


using UnityEngine;

namespace Sample.Evasion
{
    /// <summary>
    /// シンプルなステータス保持クラス。
    /// HP と Agility(敏捷)だけを持ちます。
    /// 本番プロジェクトでは、ここを拡張してもOKですが、
    /// 責務を増やしすぎないように注意しましょう。
    /// </summary>
    public class SimpleStats : MonoBehaviour
    {
        [Header("基本ステータス")]
        [SerializeField] private int maxHp = 100;
        [SerializeField] private int currentHp = 100;

        [Tooltip("回避率の計算に使う敏捷ステータス")]
        [SerializeField] private int agility = 10;

        public int MaxHp => maxHp;
        public int CurrentHp => currentHp;
        public int Agility => agility;

        /// <summary>
        /// ダメージを直接HPに適用するシンプルな関数。
        /// 回避判定などはここでは行わず、
        /// Damageable や EvasionCalculator に任せます。
        /// </summary>
        public void ApplyDamage(int damage)
        {
            // マイナスダメージが来ても安全に処理
            if (damage < 0)
            {
                Debug.LogWarning($"[SimpleStats] マイナスダメージが渡されました: {damage}");
                return;
            }

            currentHp -= damage;
            currentHp = Mathf.Clamp(currentHp, 0, maxHp);

            Debug.Log($"[SimpleStats] ダメージ {damage} を受けました。残りHP: {currentHp}");

            if (currentHp <= 0)
            {
                HandleDeath();
            }
        }

        private void HandleDeath()
        {
            // とりあえずログだけ。実際のゲームではここで死亡アニメやリスポーン処理などを呼び出します。
            Debug.Log($"[SimpleStats] {gameObject.name} は倒れました。");
        }
    }

    /// <summary>
    /// 回避判定専用のコンポーネント。
    /// Agility ステータスに基づき、攻撃を受けた際に
    /// 一定確率でダメージを無効化(Miss)します。
    /// 
    /// 責務は「回避の確率計算と判定」に限定し、
    /// 実際のダメージ適用は Damageable に任せます。
    /// </summary>
    [RequireComponent(typeof(SimpleStats))]
    public class EvasionCalculator : MonoBehaviour
    {
        [Header("回避率設定")]

        [Tooltip("Agility 1 あたりの基本回避率(0〜1)。例: 0.01f なら Agility 50 で 50% 回避。")]
        [SerializeField] private float baseEvasionPerAgility = 0.01f;

        [Tooltip("回避率の最大値(0〜1)。これ以上は上がらない上限値です。")]
        [SerializeField] private float maxEvasionRate = 0.8f;

        [Tooltip("ランダムシードを固定したい場合は true。デバッグ用。")]
        [SerializeField] private bool useFixedRandomSeed = false;

        [Tooltip("固定シード値。useFixedRandomSeed が true のときのみ使用。")]
        [SerializeField] private int fixedRandomSeed = 12345;

        private SimpleStats stats;
        private System.Random random; // System.Random を使うことでテストしやすくしています。

        private void Awake()
        {
            stats = GetComponent<SimpleStats>();

            if (useFixedRandomSeed)
            {
                random = new System.Random(fixedRandomSeed);
            }
            else
            {
                // 時間ベースのシードでランダム性を確保
                random = new System.Random(System.Environment.TickCount);
            }
        }

        /// <summary>
        /// 現在の Agility から「理論上の回避率(0〜1)」を計算して返します。
        /// 実際の判定は PerformEvasionCheck() で行います。
        /// </summary>
        public float GetCurrentEvasionRate()
        {
            // マイナス値は0として扱う
            int agility = Mathf.Max(0, stats.Agility);

            // 線形で回避率を計算
            float rate = agility * baseEvasionPerAgility;

            // 上限を適用
            rate = Mathf.Clamp01(rate);
            rate = Mathf.Min(rate, maxEvasionRate);

            return rate;
        }

        /// <summary>
        /// 回避判定を実行し、「今回の攻撃を回避したかどうか」を返します。
        /// true のときは Miss(ダメージ無効)、false のときは被弾です。
        /// </summary>
        public bool PerformEvasionCheck()
        {
            float evasionRate = GetCurrentEvasionRate();

            // 0〜1 の乱数を生成
            float roll = (float)random.NextDouble();

            bool isEvaded = roll <= evasionRate;

            Debug.Log(
                $"[EvasionCalculator] 回避判定: " +
                $"Agility={stats.Agility}, Rate={evasionRate:P1}, Roll={roll:F3}, " +
                $"Result={(isEvaded ? "MISS" : "HIT")}"
            );

            return isEvaded;
        }
    }

    /// <summary>
    /// 「ダメージを受けられる存在」を表すコンポーネント。
    /// EvasionCalculator を使用して回避判定を行い、
    /// 実際のダメージ適用を SimpleStats に委譲します。
    /// 
    /// 攻撃側からは「TakeDamage(int) を呼べばOK」という
    /// シンプルなインターフェースを提供します。
    /// </summary>
    [RequireComponent(typeof(SimpleStats))]
    public class Damageable : MonoBehaviour
    {
        [Header("ダメージ設定")]

        [Tooltip("回避判定を行う場合は true。false にすると常に被弾します。")]
        [SerializeField] private bool useEvasion = true;

        [Tooltip("回避に成功したときにログを出すかどうか。")]
        [SerializeField] private bool logOnMiss = true;

        private SimpleStats stats;
        private EvasionCalculator evasionCalculator;

        private void Awake()
        {
            stats = GetComponent<SimpleStats>();
            evasionCalculator = GetComponent<EvasionCalculator>();
        }

        /// <summary>
        /// 攻撃側から呼び出される想定のダメージ適用関数。
        /// ここで回避判定を行い、必要に応じてダメージを無効化します。
        /// </summary>
        /// <param name="rawDamage">攻撃側が計算した生のダメージ値</param>
        public void TakeDamage(int rawDamage)
        {
            if (rawDamage <= 0)
            {
                Debug.LogWarning($"[Damageable] 0以下のダメージが指定されました: {rawDamage}");
                return;
            }

            // 回避を使う設定で、かつ EvasionCalculator が存在する場合のみ判定
            if (useEvasion && evasionCalculator != null)
            {
                bool isEvaded = evasionCalculator.PerformEvasionCheck();
                if (isEvaded)
                {
                    if (logOnMiss)
                    {
                        Debug.Log($"[Damageable] {gameObject.name} は攻撃を回避しました!(MISS)");
                    }
                    // ダメージは適用しない
                    return;
                }
            }

            // 回避しなかった場合のみダメージ適用
            stats.ApplyDamage(rawDamage);
        }
    }

    /// <summary>
    /// テスト用の簡易攻撃スクリプト。
    /// キー入力に応じて、指定した Damageable にダメージを与えます。
    /// 実際のゲームでは、攻撃アニメーションやヒット判定から
    /// TakeDamage を呼び出す想定です。
    /// </summary>
    public class SimpleAttacker : MonoBehaviour
    {
        [Header("攻撃対象")]
        [SerializeField] private Damageable target;

        [Header("攻撃設定")]
        [SerializeField] private int attackDamage = 10;

        [Tooltip("テスト用に1秒間に何回攻撃するか(自動攻撃用)。0以下で自動攻撃オフ。")]
        [SerializeField] private float autoAttackPerSecond = 0f;

        private float autoAttackTimer;

        private void Update()
        {
            // スペースキーで1回攻撃(簡易テスト用)
            if (Input.GetKeyDown(KeyCode.Space))
            {
                PerformAttack();
            }

            // 自動攻撃(DPSテストなどに便利)
            if (autoAttackPerSecond > 0f && target != null)
            {
                autoAttackTimer += Time.deltaTime;
                float interval = 1f / autoAttackPerSecond;

                while (autoAttackTimer >= interval)
                {
                    autoAttackTimer -= interval;
                    PerformAttack();
                }
            }
        }

        private void PerformAttack()
        {
            if (target == null)
            {
                Debug.LogWarning("[SimpleAttacker] 攻撃対象が設定されていません。");
                return;
            }

            Debug.Log($"[SimpleAttacker] {target.gameObject.name} に {attackDamage} ダメージの攻撃!");
            target.TakeDamage(attackDamage);
        }
    }
}

使い方の手順

ここでは、プレイヤーが敵からの攻撃を受けるケースを例に、セットアップ手順を見ていきます。

  1. プレイヤー用 GameObject を用意する
    • Hierarchy で Right Click > 3D Object > Capsule などでプレイヤーの見た目を作成。
    • 名前を Player に変更しておきましょう。
  2. プレイヤーにステータスと回避コンポーネントを付ける
    • Player を選択し、Inspector で Add Component
    • SimpleStats を追加し、Max HpAgility を好みの値に設定(例: HP=100, Agility=30)。
    • 続けて EvasionCalculator を追加。
    • Base Evasion Per Agility0.01 にすると、Agility=30 で約30%回避になります。
    • 最後に Damageable を追加。Use Evasion がオンになっていることを確認。
  3. 敵(攻撃側)を用意して攻撃させる
    • Hierarchy で Right Click > 3D Object > Cube などで敵の見た目を作成。
    • 名前を Enemy に変更。
    • EnemySimpleAttacker を追加。
    • TargetPlayerDamageable をドラッグ&ドロップして設定。
    • Attack Damage を 10 などに設定。
    • テスト用途で Auto Attack Per Second1 にすると、毎秒1回自動攻撃してくれます。
  4. 再生して回避判定を確認する
    • Play ボタンを押してゲームを再生。
    • Space キーを押すか、自動攻撃をオンにしておくと、Console にログが出ます。
    • [EvasionCalculator] ログで、Rate(回避率)と Roll(乱数)が確認できます。
    • Result=MISS のときは回避成功、HIT のときは被弾です。
    • [SimpleStats] のログで、実際にHPが減ったかどうかも確認できます。

同じ仕組みは、敵キャラ動く床(トラップ) にもそのまま流用できます。

  • 敵がプレイヤー攻撃を「回避」する敵専用ステータス
  • トラップのダメージを、プレイヤーの敏捷で「すり抜けられる」かどうか判定

といった形で、「ダメージを受ける側にだけ EvasionCalculator を付ける」という統一ルールにしておくと管理がとても楽になります。

メリットと応用

EvasionCalculator をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • プレハブの再利用性が高い
    プレイヤー、雑魚敵、ボスなど、「回避できるキャラ」にはすべて同じコンポーネントを付けるだけで済みます。
    回避ロジックを変えたくなった場合も、このコンポーネント1つを修正すれば全キャラに反映されます。
  • レベルデザインがインスペクタで完結する
    AgilityBase Evasion Per AgilityMax Evasion Rate をいじるだけで、
    「この敵は回避高め」「このボスはほとんど回避しない」などを簡単に調整できます。
    シーンごとにプレハブを複製してパラメータだけ変える、といった運用もしやすいです。
  • 責務が分離されているのでテストしやすい
    回避判定は EvasionCalculator、HP管理は SimpleStats
    「ダメージを受ける窓口」は Damageable と分割しているので、
    どこかがおかしくなっても原因を特定しやすくなります。

さらに、「改造案」として、たとえば 連続で攻撃を受けると一時的に回避率が上がる ような仕組みを追加してみるのも面白いです。
以下は、EvasionCalculator に「直近数秒間の被弾数に応じてボーナス回避率を付与する」関数を追加する例です。


        /// <summary>
        /// 被弾回数に応じて一時的な回避ボーナスを計算する例。
        /// 例えば、「直近で多く攻撃されているほど、回避しやすくなる」ような
        /// ラバー・バンディング的な仕組みに応用できます。
        /// 
        /// 実際には、被弾回数をどこかでカウントして渡してあげる必要があります。
        /// </summary>
        /// <param name="recentHitCount">直近数秒間の被弾回数など</param>
        public float CalculateTemporaryEvasionBonus(int recentHitCount)
        {
            // 1回の被弾につき +2% のボーナス(例)
            float bonusPerHit = 0.02f;

            float bonus = recentHitCount * bonusPerHit;

            // ボーナスは最大 20% まで
            bonus = Mathf.Clamp(bonus, 0f, 0.2f);

            return bonus;
        }

このように、回避ロジックに関するアイデアはすべて EvasionCalculator に集約しておくと、
ゲーム全体の設計がスッキリして、後からの調整や仕様追加もやりやすくなります。
ぜひ、自分のプロジェクト用にカスタマイズしつつ、「小さな責務のコンポーネント」を積み重ねる設計を意識してみてください。