Unityでアクションゲームやコンボ制のバトルを作り始めると、つい Update() に「入力判定」「ダメージ処理」「コンボ計測」「UI更新」など、全部を押し込んでしまいがちですよね。動きはするものの、数日後にはどこを触ればいいのか分からない「神クラス(Godクラス)」が誕生してしまいます。

そんなときに意識したいのが「コンポーネント指向」と「単一責任」です。
コンボのロジックは「コンボのロジックだけ」を担当する小さなコンポーネントにしておくと、プレイヤーにも敵にも簡単に使い回せて、テストやデバッグもぐっと楽になります。

この記事では、

  • 連続ヒット数をカウントする
  • 一定時間攻撃が途切れたらコンボを自動リセットする

という役割だけに責務を絞った 「ComboCounter」コンポーネント を作っていきます。

【Unity】シンプルに盛り上がるコンボシステム!「ComboCounter」コンポーネント

フルコード:ComboCounter.cs


using UnityEngine;
using UnityEngine.Events;

/// <summary>
/// 連続ヒット数(コンボ)を管理するコンポーネント。
/// - RegisterHit() を呼ぶたびにコンボ数を加算
/// - 一定時間ヒットが無いと自動でコンボをリセット
/// - イベントで UI やエフェクト側に通知
/// 
/// 責務を「コンボの数え上げとリセット」に限定しているので、
/// 実際の攻撃判定やダメージ処理は別コンポーネントに任せる設計です。
/// </summary>
public class ComboCounter : MonoBehaviour
{
    [Header("コンボ設定")]

    [SerializeField]
    [Tooltip("コンボが維持される最大時間(秒)。この時間ヒットが無いとコンボリセット。")]
    private float comboTimeoutSeconds = 2.0f;

    [SerializeField]
    [Tooltip("この数以上のコンボになったら、OnComboThresholdReached イベントを発火します。0以下なら無効。")]
    private int highlightThreshold = 5;

    [Header("デバッグ表示")]

    [SerializeField]
    [Tooltip("現在のコンボ数をインスペクターに表示するためのフィールド(読み取り専用のつもりで使う)。")]
    private int currentCombo = 0;

    [SerializeField]
    [Tooltip("最後にヒットが発生してからの経過時間(秒)。デバッグ用。")]
    private float elapsedSinceLastHit = 0f;

    [Header("イベント")]

    [SerializeField]
    [Tooltip("コンボ数が変化したときに呼ばれるイベント。int: 現在のコンボ数")]
    private UnityEvent<int> onComboChanged;

    [SerializeField]
    [Tooltip("コンボがリセットされたときに呼ばれるイベント。")]
    private UnityEvent onComboReset;

    [SerializeField]
    [Tooltip("設定した閾値以上のコンボに到達したときに呼ばれるイベント。int: 現在のコンボ数")]
    private UnityEvent<int> onComboThresholdReached;

    /// <summary>現在のコンボ数を外部から読み取るためのプロパティ(読み取り専用)。</summary>
    public int CurrentCombo => currentCombo;

    /// <summary>コンボが有効かどうか(1以上なら true)。</summary>
    public bool HasCombo => currentCombo > 0;

    /// <summary>タイムアウト時間の変更用プロパティ。実行中に動的に変えたい場合に使用。</summary>
    public float ComboTimeoutSeconds
    {
        get => comboTimeoutSeconds;
        set => comboTimeoutSeconds = Mathf.Max(0f, value);
    }

    private void Update()
    {
        // コンボが0ならタイムアウトを気にする必要がない
        if (currentCombo <= 0)
        {
            return;
        }

        // 最後のヒットからの経過時間を加算
        elapsedSinceLastHit += Time.deltaTime;

        // 一定時間ヒットが無かったらコンボをリセット
        if (elapsedSinceLastHit >= comboTimeoutSeconds)
        {
            ResetCombo();
        }
    }

    /// <summary>
    /// ヒットが発生したときに呼び出すメソッド。
    /// 攻撃判定側のスクリプトから呼びましょう。
    /// </summary>
    public void RegisterHit()
    {
        // 経過時間をリセット
        elapsedSinceLastHit = 0f;

        // コンボ数を加算
        currentCombo++;

        // コンボ数変更イベントを通知
        onComboChanged?.Invoke(currentCombo);

        // 閾値に到達したらイベントを通知
        if (highlightThreshold > 0 && currentCombo == highlightThreshold)
        {
            onComboThresholdReached?.Invoke(currentCombo);
        }
    }

    /// <summary>
    /// 外部から明示的にコンボをリセットしたい場合に呼ぶメソッド。
    /// 例: プレイヤーがダメージを受けたときなど。
    /// </summary>
    public void ResetCombo()
    {
        if (currentCombo == 0)
        {
            // 既に0なら何もしない
            return;
        }

        currentCombo = 0;
        elapsedSinceLastHit = 0f;

        // リセットイベント
        onComboReset?.Invoke();
        // 0になったことも通知したい場合は onComboChanged も呼ぶ
        onComboChanged?.Invoke(currentCombo);
    }

    /// <summary>
    /// コンボ数を強制的に設定したい場合(デバッグ・チート用)。
    /// 通常のゲームプレイでは RegisterHit() / ResetCombo() を使うのが推奨です。
    /// </summary>
    /// <param name="value">設定したいコンボ数。0未満は0に補正。</param>
    public void SetCombo(int value)
    {
        currentCombo = Mathf.Max(0, value);
        elapsedSinceLastHit = 0f;
        onComboChanged?.Invoke(currentCombo);
    }
}

使い方の手順

ここからは、プレイヤーの攻撃コンボ を例に、実際の使い方をステップ形式で見ていきます。

手順①:コンポーネントをプロジェクトに追加

  1. ComboCounter.cs をプロジェクトの Scripts フォルダなどに保存します。
  2. Unityに戻ると自動でコンパイルされ、コンポーネントとして使えるようになります。

手順②:プレイヤー(または敵)にアタッチ

  1. シーン内の Player オブジェクトを選択。
  2. Add Component から ComboCounter を追加。
  3. インスペクターで以下を設定:
    • Combo Timeout Seconds: 例) 2.0(2秒間ヒットが途切れるとコンボ終了)
    • Highlight Threshold: 例) 5(5コンボ達成時にイベント発火)

手順③:攻撃スクリプトから RegisterHit() を呼ぶ

「敵に当たった瞬間」に RegisterHit() を1回呼べばOKです。
例として、シンプルな近接攻撃スクリプトを用意してみます。


using UnityEngine;

/// <summary>
/// 非常にシンプルな近接攻撃サンプル。
/// - 左クリックで攻撃
/// - Raycast で前方に敵がいるかチェック
/// - 当たったら ComboCounter に RegisterHit() を通知
/// 実運用ではダメージ処理やアニメーションなどを別コンポーネントに分離すると綺麗です。
/// </summary>
[RequireComponent(typeof(ComboCounter))]
public class SimpleMeleeAttack : MonoBehaviour
{
    [SerializeField]
    [Tooltip("攻撃が届く最大距離")]
    private float attackRange = 2.0f;

    [SerializeField]
    [Tooltip("攻撃が当たるレイヤー(敵など)")]
    private LayerMask hitLayerMask;

    private ComboCounter comboCounter;

    private void Awake()
    {
        // 必須コンポーネントとして ComboCounter を取得
        comboCounter = GetComponent<ComboCounter>();
    }

    private void Update()
    {
        // 左クリックで攻撃(InputSystem を使うなら別途アクションから呼ぶ形にしましょう)
        if (Input.GetMouseButtonDown(0))
        {
            TryAttack();
        }
    }

    private void TryAttack()
    {
        Ray ray = new Ray(transform.position, transform.forward);
        if (Physics.Raycast(ray, out RaycastHit hitInfo, attackRange, hitLayerMask))
        {
            // ここで本来は敵の HP を減らしたりする
            Debug.Log($"Hit: {hitInfo.collider.name}");

            // ヒットが成立したのでコンボを加算
            comboCounter.RegisterHit();
        }
    }
}

このように、「攻撃が当たったかどうか」だけを攻撃スクリプトが判断し、「コンボの管理」は ComboCounter に丸投げする形にすると、責務が綺麗に分かれます。

手順④:UIテキストやエフェクトと連携する

コンボが溜まっているときは画面に「x3」「x10」と表示したくなりますよね。
そのために ComboCounter には UnityEvent を用意してあります。

  1. コンボ数表示用の TextMeshProUGUI や UI を用意。
  2. 以下のようなシンプルな UI 更新スクリプトを作成して、Canvas 上のオブジェクトにアタッチします。

using UnityEngine;
using TMPro;

/// <summary>
/// ComboCounter と連携して、コンボ数を TextMeshPro で表示するサンプル。
/// </summary>
public class ComboTextUI : MonoBehaviour
{
    [SerializeField]
    private ComboCounter comboCounter;

    [SerializeField]
    private TextMeshProUGUI comboText;

    [SerializeField]
    [Tooltip("コンボが0のときに表示するテキスト(空なら非表示)。")]
    private string zeroComboText = "";

    private void Start()
    {
        if (comboCounter == null)
        {
            Debug.LogError("ComboCounter の参照が設定されていません。", this);
            enabled = false;
            return;
        }

        if (comboText == null)
        {
            Debug.LogError("TextMeshProUGUI の参照が設定されていません。", this);
            enabled = false;
            return;
        }

        // 初期表示を更新
        UpdateComboText(comboCounter.CurrentCombo);
    }

    /// <summary>
    /// ComboCounter の OnComboChanged イベントから呼ばれる想定のメソッド。
    /// </summary>
    public void OnComboChanged(int combo)
    {
        UpdateComboText(combo);
    }

    private void UpdateComboText(int combo)
    {
        if (combo <= 0)
        {
            if (string.IsNullOrEmpty(zeroComboText))
            {
                comboText.gameObject.SetActive(false);
            }
            else
            {
                comboText.gameObject.SetActive(true);
                comboText.text = zeroComboText;
            }
        }
        else
        {
            comboText.gameObject.SetActive(true);
            comboText.text = $"{combo} Combo!";
        }
    }
}

次に、イベントの紐付け を行います。

  1. PlayerComboCounter コンポーネントを選択。
  2. インスペクターの On Combo Changed イベントに要素を追加。
  3. ヒエラルキーから ComboTextUI を持つオブジェクトをドラッグ&ドロップ。
  4. 関数選択ドロップダウンから ComboTextUI -> OnComboChanged(int) を選択。

これで、コンボ数が変わるたびに UI が自動更新されます。

メリットと応用

メリット①:プレイヤーにも敵にも簡単に流用できる

ComboCounter は「ヒットが起きたことを報告する」ための RegisterHit() と「リセットする」ための ResetCombo() しか公開していません。
そのため、

  • プレイヤーの攻撃コンボ
  • ボスの多段ヒット攻撃(一定時間途切れるとコンボ終了)
  • トレーニングモードでの「連続ヒット数」カウント

など、どのキャラクターにもそのまま使い回せます。
プレハブに ComboCounter を事前に仕込んでおけば、レベルデザイン時には「コンボを持つ敵」としてドラッグ&ドロップするだけで済みます。

メリット②:UIやエフェクト側はイベント駆動でスッキリ

コンボのUI表示やエフェクト再生は、Update() で「今のコンボ数はいくつだろう?」と毎フレーム監視する必要はありません。
onComboChangedonComboThresholdReached のイベントにぶら下げておけば、

  • コンボが増えた瞬間だけ UI を更新
  • 5コンボ達成時にだけ専用エフェクトを再生

といった処理が自然に書けます。
これにより、UI側のスクリプトは「イベントを受けて表示を変えるだけ」という単純な責務になります。

メリット③:テストやデバッグがしやすい

ComboCounter は単体で振る舞いが完結しているので、

  • エディタ上で SetCombo() を呼んで UI の見え方を確認
  • タイムアウト時間を変えてゲームバランスを調整
  • ログを仕込んで「いつリセットされたか」を追う

といった検証がしやすくなります。
巨大な Player クラスの中にコンボ処理が埋もれている場合と比べると、圧倒的に見通しが良いです。

改造案:コンボに応じてダメージ倍率を返す

応用として、「コンボ数に応じてダメージ倍率を変えたい」というケースを考えてみましょう。
以下のようなメソッドを ComboCounter に追加するだけで、攻撃側から簡単に利用できます。


    /// <summary>
    /// 現在のコンボ数に応じてダメージ倍率を計算するサンプル。
    /// 例:
    /// - 0コンボ: 1.0倍
    /// - 1〜4コンボ: 1.0〜1.4倍
    /// - 5コンボ以上: 1.5倍で頭打ち
    /// </summary>
    public float GetDamageMultiplier()
    {
        if (currentCombo <= 0)
        {
            return 1.0f;
        }

        // コンボ数に応じて少しずつ倍率アップ(最大1.5倍)
        float multiplier = 1.0f + currentCombo * 0.1f;
        return Mathf.Min(multiplier, 1.5f);
    }

攻撃スクリプト側では、


float baseDamage = 10f;
float finalDamage = baseDamage * comboCounter.GetDamageMultiplier();

のように呼び出すだけで、「コンボが続くほど強くなる攻撃」を簡単に実装できます。

このように、コンボのロジックを1つの小さなコンポーネントに閉じ込めることで、ゲーム全体の設計がすっきりしていきます。
「Updateに全部書いてしまう」状態から一歩抜け出して、役割ごとにコンポーネントを分けていく習慣をつけていきましょう。