Unityを触り始めた頃、「とりあえず攻撃処理は全部 Update() に書いておこう」とやりがちですよね。
入力判定も、ダメージ計算も、エフェクト再生も、クリティカル判定も、全部ひとつのスクリプトにベタ書き…。
最初は動きますが、あとから「クリティカルの仕様を変えたい」「敵だけクリティカル倍率を変えたい」となった瞬間、巨大なGodクラスの中から該当コードを探して地獄を見ることになります。

そこで今回は、「クリティカル判定」という単一の責務だけを切り出したコンポーネント
CriticalHitter を用意して、攻撃ダメージ計算の中にきれいに組み込める形にしてみましょう。

【Unity】クリティカルでド派手ダメージ!「CriticalHitter」コンポーネント

CriticalHitter は、

  • クリティカル発生確率
  • クリティカル時のダメージ倍率
  • 乱数シードの管理(デバッグ再現用)

といった「クリティカル判定」に関する責務だけを担当させるコンポーネントです。
プレイヤーでも敵でも、「攻撃する側」にアタッチしておいて、
CalculateDamage() を呼び出すだけでクリティカル込みのダメージを簡単に計算できます。


フルソースコード:CriticalHitter.cs


using UnityEngine;

namespace Combat
{
    /// <summary>
    /// クリティカル判定とダメージ倍率計算だけを担当するコンポーネント。
    /// - クリティカル発生確率
    /// - クリティカル時のダメージ倍率
    /// - 乱数シード(任意)
    /// を管理し、「攻撃ダメージ計算」の中から呼び出して使います。
    /// 
    /// 責務を「クリティカル関連のロジック」に限定することで、
    /// 攻撃処理のGodクラス化を防ぎます。
    /// </summary>
    public class CriticalHitter : MonoBehaviour
    {
        [Header("クリティカル基本設定")]
        [Tooltip("0〜1の範囲で指定。0.25 なら 25% の確率でクリティカル。")]
        [SerializeField, Range(0f, 1f)]
        private float criticalChance = 0.2f;

        [Tooltip("クリティカル時のダメージ倍率。2.0 なら 2倍ダメージ。")]
        [SerializeField, Min(1f)]
        private float criticalMultiplier = 2.0f;

        [Header("デバッグ用オプション")]
        [Tooltip("true の場合、固定シードで乱数を生成して挙動を再現しやすくします。")]
        [SerializeField]
        private bool useFixedSeed = false;

        [Tooltip("useFixedSeed が true のときに使用するシード値。")]
        [SerializeField]
        private int fixedSeed = 12345;

        // 内部で使用する乱数生成器
        private System.Random random;

        /// <summary>
        /// 直近の判定がクリティカルだったかどうか。
        /// UI演出などから参照するための読み取り専用プロパティ。
        /// </summary>
        public bool LastHitWasCritical { get; private set; }

        /// <summary>
        /// 直近のクリティカル倍率(クリティカルでなければ 1.0)。
        /// ダメージ表示の色分けやログ出力に使えます。
        /// </summary>
        public float LastCriticalMultiplier { get; private set; } = 1f;

        private void Awake()
        {
            // 乱数生成器の初期化
            if (useFixedSeed)
            {
                random = new System.Random(fixedSeed);
            }
            else
            {
                // シード未指定の場合は時間ベースで適当に
                random = new System.Random();
            }
        }

        /// <summary>
        /// 基礎ダメージに対して、クリティカル判定込みの最終ダメージを計算します。
        /// 
        /// 例:
        ///   baseDamage = 10, criticalChance = 0.3, criticalMultiplier = 2.0
        ///   - 70% の確率で 10 ダメージ
        ///   - 30% の確率で 20 ダメージ
        /// </summary>
        /// <param name="baseDamage">クリティカル前の基礎ダメージ</param>
        /// <returns>クリティカル判定を反映した最終ダメージ</returns>
        public int CalculateDamage(int baseDamage)
        {
            if (baseDamage <= 0)
            {
                // マイナスやゼロダメージはそのまま返す(クリティカルさせない)
                LastHitWasCritical = false;
                LastCriticalMultiplier = 1f;
                return baseDamage;
            }

            bool isCritical = RollCritical();
            LastHitWasCritical = isCritical;
            LastCriticalMultiplier = isCritical ? criticalMultiplier : 1f;

            // 実数で倍率をかけてから四捨五入して整数に戻す
            float rawDamage = baseDamage * LastCriticalMultiplier;
            int finalDamage = Mathf.RoundToInt(rawDamage);

            return finalDamage;
        }

        /// <summary>
        /// クリティカルが発生したかどうかを判定します。
        /// このメソッド単体を使って、
        /// 「クリティカル時だけエフェクトを出す」といった用途にも利用できます。
        /// </summary>
        /// <returns>クリティカルなら true</returns>
        public bool RollCritical()
        {
            // 0.0 〜 1.0 未満の乱数を取得
            double value = random.NextDouble();

            // 乱数が「確率」より小さければクリティカル
            bool isCritical = value <= criticalChance;

            return isCritical;
        }

        /// <summary>
        /// ランタイム中にクリティカル確率を変更します。
        /// スキルやバフでの一時的な上昇などに利用できます。
        /// </summary>
        /// <param name="newChance">0〜1の範囲にクランプされます</param>
        public void SetCriticalChance(float newChance)
        {
            criticalChance = Mathf.Clamp01(newChance);
        }

        /// <summary>
        /// ランタイム中にクリティカル倍率を変更します。
        /// クリティカルダメージアップ系のバフなどに利用できます。
        /// </summary>
        /// <param name="newMultiplier">1以上を想定。1未満が指定された場合は1にクランプ。</param>
        public void SetCriticalMultiplier(float newMultiplier)
        {
            criticalMultiplier = Mathf.Max(1f, newMultiplier);
        }

        /// <summary>
        /// デバッグ用:インスペクターから現在の設定を確認しやすくするための
        /// プロパティアクセサ(読み取り専用)。
        /// </summary>
        public float CriticalChance => criticalChance;
        public float CriticalMultiplier => criticalMultiplier;
    }
}

使い方の手順

ここでは、プレイヤーが敵に攻撃するシンプルな例で使い方を説明します。
プレイヤーだけでなく、敵やトラップ、動く床の「接触ダメージ」などにも同じ要領で使えます。

手順①:攻撃者側に CriticalHitter をアタッチ

  • 空の GameObject でも、プレイヤーのルートオブジェクトでもOKです。
  • Inspector で「Add Component」→ CriticalHitter を追加します。
  • Critical Chance(例:0.25 = 25%)と Critical Multiplier(例:2.5倍)を好みに設定します。

手順②:シンプルな攻撃スクリプトから呼び出す

攻撃ロジックは別コンポーネントに分けて、CriticalHitter を「ダメージ計算の道具」として使います。
以下は、プレイヤーがスペースキーで敵にダメージを与える非常にシンプルな例です。


using UnityEngine;
using Combat; // CriticalHitter の namespace

/// <summary>
/// 非常にシンプルな攻撃サンプル。
/// 入力と当たり判定とダメージ計算を最低限に抑え、
/// クリティカル計算だけを CriticalHitter に委譲します。
/// </summary>
public class SimpleAttack : MonoBehaviour
{
    [Header("攻撃設定")]
    [SerializeField]
    private int baseDamage = 10;

    [Tooltip("攻撃対象とするレイヤー(例:Enemy)")]
    [SerializeField]
    private LayerMask targetLayer;

    [Tooltip("攻撃が届く最大距離")]
    [SerializeField]
    private float attackRange = 2f;

    [Header("参照コンポーネント")]
    [SerializeField]
    private CriticalHitter criticalHitter;

    private void Reset()
    {
        // 同じ GameObject 上に CriticalHitter があれば自動参照
        criticalHitter = GetComponent<CriticalHitter>();
    }

    private void Update()
    {
        // 超シンプルな入力例:スペースキーで攻撃
        if (Input.GetKeyDown(KeyCode.Space))
        {
            TryAttack();
        }
    }

    private void TryAttack()
    {
        if (criticalHitter == null)
        {
            Debug.LogWarning("CriticalHitter が設定されていません。ノンクリティカルで計算します。", this);
        }

        // 正面方向に Ray を飛ばして敵を判定する簡易版
        Ray ray = new Ray(transform.position, transform.forward);
        if (Physics.Raycast(ray, out RaycastHit hit, attackRange, targetLayer))
        {
            int finalDamage = baseDamage;

            if (criticalHitter != null)
            {
                // クリティカル判定込みでダメージを計算
                finalDamage = criticalHitter.CalculateDamage(baseDamage);
            }

            // ダメージを与える相手は IDamageable などのインターフェースにしておくと綺麗
            var damageable = hit.collider.GetComponent<IDamageable>();
            if (damageable != null)
            {
                damageable.TakeDamage(finalDamage);

                // ログにクリティカル情報を出してみる
                if (criticalHitter != null && criticalHitter.LastHitWasCritical)
                {
                    Debug.Log($"CRITICAL! {finalDamage} ダメージ!(倍率: {criticalHitter.LastCriticalMultiplier:F1})");
                }
                else
                {
                    Debug.Log($"{finalDamage} ダメージ");
                }
            }
        }
    }
}

/// <summary>
/// ダメージを受ける側が実装するインターフェースの例。
/// </summary>
public interface IDamageable
{
    void TakeDamage(int damage);
}

手順③:敵側にダメージ受け取りスクリプトを用意

敵側は「ダメージを受ける」という責務だけを持つコンポーネントにしておくと、プレイヤー以外にも使い回せて便利です。


using UnityEngine;

/// <summary>
/// 非常にシンプルな敵のHP管理。
/// IDamageable を実装しているので SimpleAttack からダメージを受け取れます。
/// </summary>
public class SimpleEnemy : MonoBehaviour, IDamageable
{
    [SerializeField]
    private int maxHp = 50;

    private int currentHp;

    private void Awake()
    {
        currentHp = maxHp;
    }

    public void TakeDamage(int damage)
    {
        currentHp -= damage;
        Debug.Log($"{gameObject.name} は {damage} ダメージを受けた!(残りHP: {currentHp})");

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

    private void Die()
    {
        Debug.Log($"{gameObject.name} は倒れた!");
        // 実際のゲームではアニメーション再生やドロップ処理などを書く
        Destroy(gameObject);
    }
}

手順④:シーンに配置してテスト

  1. シーンに「Player」オブジェクトを作成し、SimpleAttackCriticalHitter をアタッチ。
  2. 「Enemy」オブジェクトを作り、SimpleEnemy をアタッチし、レイヤーを「Enemy」などに設定。
  3. SimpleAttack.targetLayer に「Enemy」レイヤーを設定。
  4. 再生して、プレイヤーを敵の方向に向けてスペースキーを押すと、ランダムにクリティカルが発生し、ログに倍率付きで表示されます。

動く床やトラップに使う場合は、OnTriggerEnter などで CalculateDamage を呼び出すだけで、同じクリティカルロジックを再利用できます。


メリットと応用

CriticalHitter を分離しておくメリットはかなり大きいです。

  • プレハブごとにクリティカル仕様を分けられる
    プレイヤーは「25% で 2倍」、ボスは「10% で 5倍」、雑魚敵は「5% で 1.5倍」など、
    プレハブのインスペクターから数値をいじるだけで簡単に差別化できます。
  • レベルデザイン時に数値調整がしやすい
    「このステージはクリティカル出やすくして爽快感マシマシにしよう」など、
    シーン上のオブジェクトにアタッチされた CriticalHitter の値をGUIから直接いじるだけでテストできます。
  • 演出との連携がシンプル
    LastHitWasCriticalLastCriticalMultiplier を参照して、
    ダメージテキストを赤くしたり、カメラを揺らしたり、といった「派手な数字演出」を簡単に追加できます。
  • Godクラス化を防げる
    クリティカル判定ロジックを攻撃スクリプトから切り離すことで、
    攻撃処理は「いつ・誰に・いくらダメージを与えるか」だけに集中でき、
    クリティカルは専用コンポーネントに任せる、というきれいな責務分割ができます。

改造案:クリティカル時だけ派手な数字を出す

例えば、ダメージテキストを出すコンポーネントから CriticalHitter を参照して、
クリティカル時だけ色やサイズを変えることができます。


using UnityEngine;
using TMPro;
using Combat;

/// <summary>
/// ダメージ表示用の簡易コンポーネント例。
/// CriticalHitter の結果を参照して、クリティカル時だけ演出を派手にします。
/// </summary>
public class DamageTextSpawner : MonoBehaviour
{
    [SerializeField]
    private TextMeshPro damageTextPrefab;

    [SerializeField]
    private Color normalColor = Color.white;

    [SerializeField]
    private Color criticalColor = Color.yellow;

    [SerializeField]
    private float normalScale = 1f;

    [SerializeField]
    private float criticalScale = 1.5f;

    [SerializeField]
    private CriticalHitter criticalHitter;

    public void SpawnDamageText(int damage, Vector3 worldPosition)
    {
        TextMeshPro text = Instantiate(damageTextPrefab, worldPosition, Quaternion.identity);
        text.text = damage.ToString();

        bool isCritical = criticalHitter != null && criticalHitter.LastHitWasCritical;
        text.color = isCritical ? criticalColor : normalColor;
        text.transform.localScale = Vector3.one * (isCritical ? criticalScale : normalScale);
    }
}

このように、CriticalHitter を「クリティカルの真偽と倍率を教えてくれるコンポーネント」として扱うことで、
ダメージ表示・サウンド・カメラシェイクなどの演出コンポーネントときれいに連携できます。

クリティカル判定というひとつの機能を小さなコンポーネントに切り出しておくと、
ゲーム全体の設計もすっきりして、プレハブ管理やレベルデザインがかなり楽になります。
「大きな攻撃スクリプトに全部詰め込む」のではなく、「小さな責務ごとにコンポーネントを分ける」方向で組み立てていきましょう。