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>
/// ¶m 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;
}
}
}
}
使い方の手順
-
スクリプトの配置
- 上記の
InputBuffer.csをAssets/Scriptsなどに保存します。 - 必要であれば、サンプルの
SimpleAttackController.csも同様に保存します。
- 上記の
-
プレイヤーにコンポーネントを追加
例としてプレイヤーキャラクターに入力バッファを付ける場合:- シーン内のプレイヤー GameObject を選択
Add ComponentからInputBufferを追加- 同様に、サンプルを試す場合は
SimpleAttackControllerも追加 InputBufferのインスペクタでBuffer Duration… 0.15〜0.3秒程度に設定(好みで調整)Max Buffered Inputs… 1 か 2 程度に設定
-
入力イベントからバッファ登録を呼ぶ
実際のプロジェクトでは、新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だけ」「攻撃可能ならその場で攻撃開始」といった分担にすると綺麗です。 -
攻撃モーション終了時に 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); // 任意の攻撃開始処理 } }のように、アニメーションの終了と同期させる方法もあります。
どちらの方法でも、「入力の記憶」と「攻撃の実行」を分けておけるのがポイントです。
-
① スクリプトで時間管理する(SimpleAttackController のような方式)
具体的な使用例
-
プレイヤーキャラのコンボ攻撃
弱攻撃1段目のモーション中に次の弱攻撃ボタンを押しても、タイミングがシビアだと「入力が吸われる」問題が出やすいです。
InputBufferを使えば、1段目の終わり際に押された入力を覚えておき、モーション終了直後に自動で2段目に繋げられます。 -
敵キャラの行動予約
敵AIが「次は突進しよう」と決めたけれど、今は攻撃モーション中で動けない…というケースでも、
AI側からRegisterInput(BufferedInputType.Dash)を呼び、モーション終了時にConsumeBufferedInputして突進に繋げる、といった使い方ができます。 -
動く床やギミックの入力受付
スイッチを押すと動く床が動き出すギミックで、「床が動いている間は再入力を受け付けない」ようにしたい場合、
動いている間に押されたスイッチ入力をInputBufferに溜めておき、床の移動終了時にまとめて処理する、という形にも応用できます。
メリットと応用
InputBuffer を導入することで、
- 攻撃ロジックから「入力のタイミング問題」を切り離せる
「いつボタンが押されたか」ではなく、「いつ攻撃を開始できるか」だけに集中して攻撃コントローラを書けるようになります。 - プレハブ単位で挙動を調整しやすい
bufferDurationやmaxBufferedInputsをインスペクタから変えるだけで、
「このキャラは入力受付が甘め」「このボスはシビア」といった調整がプレハブレベルで完結します。 - レベルデザインが楽になる
プレイヤーの入力受付猶予を調整するだけで、「気持ちよくコンボが繋がる」か「シビアで高難度」かを簡単に切り替えられます。
ステージごとにキャラのプレハブを差し替えるだけで、ゲーム全体の手触りを変えられるのはかなり強力です。 - コンポーネント指向で再利用しやすい
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;
}
このように、小さな責務のコンポーネントとして作っておくと、
「古い順」「新しい順」「特定タイプだけ優先」など、ゲームに合わせた改造がとてもやりやすくなります。
ぜひ自分のプロジェクトに合わせて育ててみてください。
