Unityを触り始めた頃って、つい何でもかんでも Update() に書いてしまいがちですよね。
「ボタン入力を取る」「攻撃中かどうかを見る」「攻撃が終わったら次の攻撃を出す」…と全部を1つのスクリプトに押し込むと、あっという間に数百行のGodクラスになってしまいます。

そうなると、

  • 攻撃処理のバグを直したいだけなのに、移動コードまで読まないと理解できない
  • コンボ入力の仕様変更をしようとしたら、既存の大量のif文を壊しそうで怖い
  • プレイヤーと敵で似たようなロジックをコピペしてしまう

といった問題が発生しやすくなります。

そこで今回は、「入力を覚えておいて、攻撃モーションが終わった瞬間に発動させる」という機能だけに責務を絞ったコンポーネント InputBuffer(入力バッファ) を作ってみましょう。
攻撃アニメーションの制御や移動処理とは分離して、「入力を一時的に覚えておく」ことだけを担当させることで、コードの見通しがかなり良くなります。

【Unity】攻撃キャンセルをスムーズに!「InputBuffer」コンポーネント

ここでは、

  • 攻撃中に押されたボタンを一時的に記憶する
  • 攻撃が終わった瞬間に、その記憶していた入力を「発動すべき入力」として取り出す
  • 一定時間だけ有効な「入力バッファ時間」を設定できる

といった挙動を持つ InputBuffer コンポーネントを作ります。

入力の取得自体は別コンポーネント(例: PlayerInput や 攻撃コントローラ)が行い、
「今の入力をバッファに登録しておいて」「攻撃が終わったから、溜まってる入力があれば教えて」といった形で、明確な役割分担をするのがポイントです。

フルコード:InputBuffer.cs


using System;
using UnityEngine;

namespace Sample.InputBufferSystem
{
    /// <summary>
    /// 攻撃モーション中など、すぐには処理できない入力を一時的に記憶しておくコンポーネント。
    /// 
    /// - 「入力が来た」タイミングで RegisterInput を呼ぶ
    /// - 「攻撃が終わった」タイミングで ConsumeBufferedInput を呼ぶ
    /// 
    /// という2ステップで使います。
    /// 
    /// 入力の種類は enum で定義し、拡張しやすい形にしています。
    /// </summary>
    public class InputBuffer : MonoBehaviour
    {
        /// <summary>
        /// バッファ対象となる入力の種類。
        /// 必要に応じて種類を追加して使ってください。
        /// </summary>
        public enum BufferedInputType
        {
            None = 0,
            LightAttack,   // 弱攻撃
            HeavyAttack,   // 強攻撃
            Dash,          // ダッシュ
            Jump           // ジャンプ
        }

        [Header("バッファ設定")]

        [SerializeField]
        [Tooltip("入力を保持しておく最大時間(秒)。この時間を過ぎた入力は無効になります。")]
        private float bufferDuration = 0.2f;

        [SerializeField]
        [Tooltip("同時に保持できる入力の最大数。1なら常に最新の1件だけを保持します。")]
        private int maxBufferedInputs = 1;

        /// <summary>
        /// バッファに新しい入力が登録されたときに呼ばれるイベント。
        /// UI 表示の更新などに利用できます。
        /// </summary>
        public event Action<BufferedInputType> OnInputBuffered;

        /// <summary>
        /// バッファされていた入力が消費(取り出し)されたときに呼ばれるイベント。
        /// </summary>
        public event Action<BufferedInputType> OnInputConsumed;

        /// <summary>
        /// バッファの中身を表す1件分の構造体。
        /// </summary>
        [Serializable]
        private struct BufferedInput
        {
            public BufferedInputType type;  // 入力の種類
            public float time;              // 入力された時刻(Time.time)
        }

        // 固定長の簡易キューとして扱うための配列
        private BufferedInput[] _bufferedInputs;
        private int _count;

        private void Awake()
        {
            // 最大数に応じて配列を初期化
            if (maxBufferedInputs <= 0)
            {
                maxBufferedInputs = 1;
            }

            _bufferedInputs = new BufferedInput[maxBufferedInputs];
            _count = 0;
        }

        private void Update()
        {
            // 毎フレーム、古くなった入力を自動的に破棄します。
            RemoveExpiredInputs();
        }

        /// <summary>
        /// 新しい入力をバッファに登録します。
        /// 攻撃中にボタンが押されたタイミングで呼び出してください。
        /// </summary>
        /// &param name="inputType">登録したい入力の種類</param>
        public void RegisterInput(BufferedInputType inputType)
        {
            if (inputType == BufferedInputType.None)
            {
                return;
            }

            // 古いものを先に掃除しておく
            RemoveExpiredInputs();

            // バッファが満杯なら、一番古いものを捨てて詰める
            if (_count >= maxBufferedInputs)
            {
                // 0番目を捨てて、1つずつ前に詰める
                for (int i = 1; i < _count; i++)
                {
                    _bufferedInputs[i - 1] = _bufferedInputs[i];
                }

                _count = Mathf.Max(0, _count - 1);
            }

            // 末尾に新しい入力を追加
            _bufferedInputs[_count] = new BufferedInput
            {
                type = inputType,
                time = Time.time
            };
            _count++;

            OnInputBuffered?.Invoke(inputType);
        }

        /// <summary>
        /// バッファされている入力のうち、最も古いものを取り出して返します。
        /// 攻撃モーションの終了直後など、「今なら入力を受け付けられる」タイミングで呼び出してください。
        /// </summary>
        /// &returns>有効な入力があればその種類、なければ BufferedInputType.None</returns>
        public BufferedInputType ConsumeBufferedInput()
        {
            // まずは期限切れを掃除
            RemoveExpiredInputs();

            if (_count == 0)
            {
                return BufferedInputType.None;
            }

            // 最も古い入力(0番目)を取り出す
            BufferedInput consumed = _bufferedInputs[0];

            // 残りを前に詰める
            for (int i = 1; i < _count; i++)
            {
                _bufferedInputs[i - 1] = _bufferedInputs[i];
            }

            _count--;

            OnInputConsumed?.Invoke(consumed.type);
            return consumed.type;
        }

        /// <summary>
        /// 現在バッファに何件入力が溜まっているかを返します。
        /// デバッグ用途などにどうぞ。
        /// </summary>
        public int GetBufferedCount()
        {
            RemoveExpiredInputs();
            return _count;
        }

        /// <summary>
        /// バッファをすべてクリアします。
        /// 例: キャラクターが怯み・ダウンしたときなどに呼び出すと安全です。
        /// </summary>
        public void ClearBuffer()
        {
            _count = 0;
        }

        /// <summary>
        /// バッファ内の期限切れ入力を削除します。
        /// </summary>
        private void RemoveExpiredInputs()
        {
            if (_count == 0)
            {
                return;
            }

            float now = Time.time;
            int startIndex = 0;

            // 先頭から順に、有効期限を過ぎた入力を探す
            while (startIndex < _count)
            {
                float elapsed = now - _bufferedInputs[startIndex].time;
                if (elapsed > bufferDuration)
                {
                    // この要素は期限切れなので、次の要素へ
                    startIndex++;
                }
                else
                {
                    // ここから先はまだ有効なのでループ終了
                    break;
                }
            }

            if (startIndex == 0)
            {
                // 1件も期限切れがなければ何もしない
                return;
            }

            if (startIndex >= _count)
            {
                // 全部期限切れなら、まとめてクリア
                _count = 0;
                return;
            }

            // 有効なものだけを先頭に詰める
            int newCount = _count - startIndex;
            for (int i = 0; i < newCount; i++)
            {
                _bufferedInputs[i] = _bufferedInputs[i + startIndex];
            }

            _count = newCount;
        }

        /// <summary>
        /// 現在バッファに溜まっている最も古い入力の種類を確認します。
        /// 消費はせず、覗き見るだけです。
        /// </summary>
        public BufferedInputType PeekOldest()
        {
            RemoveExpiredInputs();
            if (_count == 0)
            {
                return BufferedInputType.None;
            }

            return _bufferedInputs[0].type;
        }

        /// <summary>
        /// 現在バッファに溜まっている最も新しい入力の種類を確認します。
        /// 消費はせず、覗き見るだけです。
        /// </summary>
        public BufferedInputType PeekLatest()
        {
            RemoveExpiredInputs();
            if (_count == 0)
            {
                return BufferedInputType.None;
            }

            return _bufferedInputs[_count - 1].type;
        }
    }
}

補助例:攻撃コントローラと組み合わせるサンプル

上記の InputBuffer は「入力を覚えるだけ」なので、実際の攻撃処理とは分離されています。
ここでは、簡易的な攻撃コントローラと組み合わせた例を載せておきます。


using UnityEngine;
using Sample.InputBufferSystem;

namespace Sample.Player
{
    /// <summary>
    /// 非常にシンプルな攻撃コントローラの例。
    /// - スペースキーで弱攻撃
    /// - 左クリックで強攻撃
    /// 
    /// 攻撃中に押された入力は InputBuffer に登録し、
    /// 攻撃が終わった瞬間に ConsumeBufferedInput で取り出して次の攻撃へ繋げます。
    /// </summary>
    [RequireComponent(typeof(InputBuffer))]
    public class SimpleAttackController : MonoBehaviour
    {
        [Header("攻撃時間(秒)")]
        [SerializeField] private float lightAttackDuration = 0.3f;
        [SerializeField] private float heavyAttackDuration = 0.5f;

        private InputBuffer _inputBuffer;

        private bool _isAttacking;
        private float _attackEndTime;

        private void Awake()
        {
            _inputBuffer = GetComponent<InputBuffer>();
        }

        private void Update()
        {
            HandleRawInput();
            UpdateAttackState();
        }

        /// <summary>
        /// 生の入力を受け取り、攻撃中ならバッファに登録、
        /// 攻撃中でなければ即座に攻撃を開始します。
        /// 
        /// 本番では新InputSystemやInputActionを使うとより綺麗に書けますが、
        /// ここでは分かりやすさのために Input.GetKey 系を使っています。
        /// </summary>
        private void HandleRawInput()
        {
            // 弱攻撃: スペースキー
            if (Input.GetKeyDown(KeyCode.Space))
            {
                OnAttackButtonPressed(InputBuffer.BufferedInputType.LightAttack);
            }

            // 強攻撃: 左クリック
            if (Input.GetMouseButtonDown(0))
            {
                OnAttackButtonPressed(InputBuffer.BufferedInputType.HeavyAttack);
            }
        }

        /// <summary>
        /// 攻撃ボタンが押されたときの共通処理。
        /// </summary>
        private void OnAttackButtonPressed(InputBuffer.BufferedInputType type)
        {
            if (_isAttacking)
            {
                // すでに攻撃中なら、今の入力はバッファに登録だけしておく
                _inputBuffer.RegisterInput(type);
            }
            else
            {
                // 攻撃可能な状態なら、即座に攻撃開始
                StartAttack(type);
            }
        }

        /// <summary>
        /// 攻撃状態の更新。
        /// 攻撃終了時に、バッファされた入力を消費して次の攻撃を自動的に開始します。
        /// </summary>
        private void UpdateAttackState()
        {
            if (!_isAttacking)
            {
                return;
            }

            if (Time.time >= _attackEndTime)
            {
                // 1つの攻撃が終了
                _isAttacking = false;

                // バッファに溜まっている入力があれば1件だけ消費して次の攻撃を開始
                var buffered = _inputBuffer.ConsumeBufferedInput();
                if (buffered != InputBuffer.BufferedInputType.None)
                {
                    StartAttack(buffered);
                }
            }
        }

        /// <summary>
        /// 実際に攻撃を開始する処理。
        /// ここではアニメーションは使わず、コンソールログだけ出しています。
        /// 実運用では Animator や アニメーションイベントと組み合わせると良いです。
        /// </summary>
        private void StartAttack(InputBuffer.BufferedInputType type)
        {
            _isAttacking = true;

            switch (type)
            {
                case InputBuffer.BufferedInputType.LightAttack:
                    _attackEndTime = Time.time + lightAttackDuration;
                    Debug.Log("Light Attack!");
                    break;

                case InputBuffer.BufferedInputType.HeavyAttack:
                    _attackEndTime = Time.time + heavyAttackDuration;
                    Debug.Log("Heavy Attack!");
                    break;

                default:
                    // 定義されていないタイプは無視
                    _isAttacking = false;
                    break;
            }
        }
    }
}

使い方の手順

  1. スクリプトの配置
    • 上記の InputBuffer.csAssets/Scripts などに保存します。
    • 必要であれば、サンプルの SimpleAttackController.cs も同様に保存します。
  2. プレイヤーにコンポーネントを追加
    例としてプレイヤーキャラクターに入力バッファを付ける場合:
    • シーン内のプレイヤー GameObject を選択
    • Add Component から InputBuffer を追加
    • 同様に、サンプルを試す場合は SimpleAttackController も追加
    • InputBuffer のインスペクタで
      • Buffer Duration … 0.15〜0.3秒程度に設定(好みで調整)
      • Max Buffered Inputs … 1 か 2 程度に設定
  3. 入力イベントからバッファ登録を呼ぶ
    実際のプロジェクトでは、新InputSystemの InputAction から呼ぶことが多いと思います。例:
    
    public class PlayerInputBridge : MonoBehaviour
    {
        [SerializeField] private InputBuffer inputBuffer;
    
        // 新InputSystemのイベントコールバックなどから呼ぶ想定
        public void OnLightAttack()
        {
            inputBuffer.RegisterInput(InputBuffer.BufferedInputType.LightAttack);
        }
    
        public void OnHeavyAttack()
        {
            inputBuffer.RegisterInput(InputBuffer.BufferedInputType.HeavyAttack);
        }
    }
    

    攻撃中かどうかは別コンポーネント(攻撃コントローラ)で管理し、
    「攻撃中なら RegisterInput だけ」「攻撃可能ならその場で攻撃開始」といった分担にすると綺麗です。

  4. 攻撃モーション終了時に ConsumeBufferedInput を呼ぶ
    代表的なパターンは2つあります。
    • ① スクリプトで時間管理する(SimpleAttackController のような方式)
      攻撃開始時に「終了予定時刻」を記録し、その時刻を過ぎたら
      
      var buffered = inputBuffer.ConsumeBufferedInput();
      if (buffered != InputBuffer.BufferedInputType.None)
      {
          // buffered の種類に応じて次の攻撃を開始
      }
      

      といった処理を行います。

    • ② アニメーションイベントから呼ぶ
      攻撃アニメーションの最後のフレームに「AttackEnd」というイベントを仕込み、
      
      public void OnAttackAnimationEnd()
      {
          var buffered = inputBuffer.ConsumeBufferedInput();
          if (buffered != InputBuffer.BufferedInputType.None)
          {
              StartAttack(buffered); // 任意の攻撃開始処理
          }
      }
      

      のように、アニメーションの終了と同期させる方法もあります。

    どちらの方法でも、「入力の記憶」と「攻撃の実行」を分けておけるのがポイントです。

具体的な使用例

  • プレイヤーキャラのコンボ攻撃
    弱攻撃1段目のモーション中に次の弱攻撃ボタンを押しても、タイミングがシビアだと「入力が吸われる」問題が出やすいです。
    InputBuffer を使えば、1段目の終わり際に押された入力を覚えておき、モーション終了直後に自動で2段目に繋げられます。
  • 敵キャラの行動予約
    敵AIが「次は突進しよう」と決めたけれど、今は攻撃モーション中で動けない…というケースでも、
    AI側から RegisterInput(BufferedInputType.Dash) を呼び、モーション終了時に ConsumeBufferedInput して突進に繋げる、といった使い方ができます。
  • 動く床やギミックの入力受付
    スイッチを押すと動く床が動き出すギミックで、「床が動いている間は再入力を受け付けない」ようにしたい場合、
    動いている間に押されたスイッチ入力を InputBuffer に溜めておき、床の移動終了時にまとめて処理する、という形にも応用できます。

メリットと応用

InputBuffer を導入することで、

  • 攻撃ロジックから「入力のタイミング問題」を切り離せる
    「いつボタンが押されたか」ではなく、「いつ攻撃を開始できるか」だけに集中して攻撃コントローラを書けるようになります。
  • プレハブ単位で挙動を調整しやすい
    bufferDurationmaxBufferedInputs をインスペクタから変えるだけで、
    「このキャラは入力受付が甘め」「このボスはシビア」といった調整がプレハブレベルで完結します。
  • レベルデザインが楽になる
    プレイヤーの入力受付猶予を調整するだけで、「気持ちよくコンボが繋がる」か「シビアで高難度」かを簡単に切り替えられます。
    ステージごとにキャラのプレハブを差し替えるだけで、ゲーム全体の手触りを変えられるのはかなり強力です。
  • コンポーネント指向で再利用しやすい
    InputBuffer 自体は「入力を覚えるだけ」のコンポーネントなので、
    プレイヤー、敵、ギミックなど、どのオブジェクトにも同じものを再利用できます。
    それぞれの「行動内容」は別コンポーネントに閉じ込めておけるので、Godクラス化を防げます。

改造案:最後に入力されたものだけを優先するモード

今の実装では「最も古い入力から順に消費」していますが、アクションゲームによっては
「直近で押されたボタンを優先して処理したい」というケースもあります。

その場合の一例として、「最新の入力だけを取り出す」ヘルパーメソッドを追加してみましょう。


    /// <summary>
    /// バッファされている入力のうち、最も新しいものを1件だけ消費して返します。
    /// 「直前の入力を優先したい」ゲームデザインの場合に使えます。
    /// </summary>
    public InputBuffer.BufferedInputType ConsumeLatestInput()
    {
        RemoveExpiredInputs();

        if (_count == 0)
        {
            return InputBuffer.BufferedInputType.None;
        }

        // 最も新しい入力(_count - 1番目)を取り出す
        var consumed = _bufferedInputs[_count - 1];
        _count--;

        OnInputConsumed?.Invoke(consumed.type);
        return consumed.type;
    }

このように、小さな責務のコンポーネントとして作っておくと、
「古い順」「新しい順」「特定タイプだけ優先」など、ゲームに合わせた改造がとてもやりやすくなります。
ぜひ自分のプロジェクトに合わせて育ててみてください。