アクションゲームをGodotで作っていると、こんな悩みが出てきませんか?

  • 攻撃モーション中に次の攻撃ボタンを押しても「無反応」でストレス…
  • アニメーションの終わりの「数フレームだけ」入力を受け付ける実装が面倒…
  • プレイヤー / 敵 / ボスごとに似たような「コンボ用の入力受付ロジック」をコピペしてしまう…

Godot標準のやり方だと、ついこうなりがちです:

  • プレイヤーの状態マシン(State)に「次入力用の変数」を直書き
  • アニメーションの終わりで「if next_attack_queued: …」みたいな分岐が増殖
  • ノード階層が Player → StateMachine → AttackState → ComboLogic… とどんどん深くなる

これだと、キャラを増やすたびに状態マシンを複製・改造するハメになって、メンテがつらいですね。
そこで今回は、「入力バッファ」を独立コンポーネントとして切り出してしまいましょう。

攻撃モーション中に押されたボタンを記憶しておき、モーション終了直後に発動させる。
このロジックを InputBuffer コンポーネントとして実装し、どのキャラにもポン付けできるようにします。


【Godot 4】コンボ入力を逃さない!「InputBuffer」コンポーネント

今回の InputBuffer は、ざっくり言うと「入力の予約キュー」です。

  • 攻撃中など「今は受け付けられない」タイミングで押されたボタンを一時保存
  • 攻撃終了など「受付再開」のタイミングで、溜まっていた入力を即座に吐き出す
  • 一定時間だけ有効な「入力猶予フレーム(バッファ時間)」も設定可能

継承ではなく「コンポーネント」として作ることで、

  • プレイヤーキャラ
  • 敵AI(連続攻撃のトリガーに)
  • トレーニング用ダミー(入力可視化などにも応用可)

など、どんなノードにもアタッチして再利用できるようにしていきます。


フルコード:InputBuffer.gd


extends Node
class_name InputBuffer
## 入力バッファコンポーネント
## - 攻撃モーション中など「今は処理できない」入力を一時的に保存
## - モーション終了直後に、溜まっていた入力をまとめて通知する
## - バッファ時間(入力猶予)も設定可能

## --- 設定パラメータ ---------------------------------------------------------

## 何秒間、入力をバッファしておくか(0 なら「そのフレームだけ」)
## 例: 0.15 秒 = 約 9 フレーム(60fps 換算)
@export_range(0.0, 1.0, 0.01)
var buffer_time: float = 0.15

## どのアクション名をバッファ対象にするか
## - Godot の InputMap に登録されているアクション名を指定
## - 例: ["attack_light", "attack_heavy", "jump"]
@export var watched_actions: Array[StringName] = ["attack"]

## バッファした入力を「いつ吐き出すか」のモード
enum FlushMode {
	## allowed = true になった瞬間にまとめて吐き出す
	ON_ALLOWED_ENABLED,
	## ユーザーが明示的に flush_buffer() を呼んだときのみ
	MANUAL,
}
@export var flush_mode: FlushMode = FlushMode.ON_ALLOWED_ENABLED

## 攻撃中など「今は入力を処理できない」状態かどうか
## - 親ノード(プレイヤーなど)から制御するフラグ
## - true の間に押された入力はバッファに溜める
var allowed: bool = true :
	set(value):
		if allowed == value:
			return
		allowed = value
		# false → true になった瞬間にバッファを吐き出すモード
		if allowed and flush_mode == FlushMode.ON_ALLOWED_ENABLED:
			_flush_and_emit()

## バッファに同時にいくつまで溜めるか
## - 0 以下なら無制限
## - 1 にすると「最後の入力だけ保持」する挙動にできる
@export_range(0, 16, 1)
var max_buffer_size: int = 4

## デバッグ用: バッファ内容を print するかどうか
@export var debug_print: bool = false

## --- シグナル --------------------------------------------------------------

## バッファから入力が吐き出されるときに通知
## - action_name: "attack" など InputMap のアクション名
## - strength: 押し込み強度(通常は 1.0)
signal buffered_action_fired(action_name: StringName, strength: float)

## --- 内部データ構造 --------------------------------------------------------

## バッファ1件分のデータ
class BufferedInput:
	var action_name: StringName
	var strength: float
	var time: float  ## 入力が行われた時刻(秒)

	func _init(_action_name: StringName, _strength: float, _time: float) -> void:
		action_name = _action_name
		strength = _strength
		time = _time

## 実際のバッファ配列
var _buffer: Array[BufferedInput] = []

## --- ライフサイクル --------------------------------------------------------

func _ready() -> void:
	# 親ノードがある前提での利用が多いので、名前付きで見つけやすくしておく
	if debug_print:
		print("[InputBuffer] ready on node: ", get_parent())

func _process(_delta: float) -> void:
	# 毎フレーム、バッファの有効期限をチェックして古いものを捨てる
	_prune_expired_inputs()

## --- 入力監視ロジック ------------------------------------------------------

func _unhandled_input(event: InputEvent) -> void:
	# キーボード / パッド / マウスボタンなどの「アクション入力」を対象とする
	if event.is_echo():
		# 長押しのリピートは無視したい場合はここで弾く
		return

	for action_name in watched_actions:
		# 押された瞬間のみ検出
		if event.is_action_pressed(action_name):
			var strength := event.get_action_strength(action_name)
			_handle_action_pressed(action_name, strength)

func _handle_action_pressed(action_name: StringName, strength: float) -> void:
	var now := Time.get_ticks_msec() / 1000.0

	if allowed:
		# いま入力を受け付けられる状態なら、即座にシグナル発火
		if debug_print:
			print("[InputBuffer] immediate fire: ", action_name, " strength=", strength)
		buffered_action_fired.emit(action_name, strength)
	else:
		# 受け付けられない状態なら、バッファに積む
		var input := BufferedInput.new(action_name, strength, now)

		# max_buffer_size が 1 のときは「最後の入力だけ保持」したいケースが多い
		if max_buffer_size == 1:
			_buffer.clear()
			_buffer.append(input)
		else:
			_buffer.append(input)
			if max_buffer_size > 0 and _buffer.size() > max_buffer_size:
				# 古いものから順に捨てる
				_buffer.pop_front()

		if debug_print:
			print("[InputBuffer] buffered: ", action_name, " (size=", _buffer.size(), ")")

## --- 公開API ---------------------------------------------------------------

## 明示的にバッファを吐き出したいときに呼ぶ
## - 例: 攻撃アニメーションの AnimationPlayer のアニメーション終了コールバックで呼ぶ
func flush_buffer() -> void:
	_flush_and_emit()

## 現在バッファされているアクション名の配列を返す(デバッグ・可視化用)
func get_buffered_actions() -> Array[StringName]:
	var result: Array[StringName] = []
	for bi in _buffer:
		result.append(bi.action_name)
	return result

## バッファを空にする(何も発火させない)
func clear_buffer() -> void:
	_buffer.clear()
	if debug_print:
		print("[InputBuffer] buffer cleared")

## allowed フラグを一時的に変更するヘルパー
## - 状態マシンを使っている場合など、外部から直接 allowed をいじるよりも
##   「意図が分かりやすい」ラッパーとして用意
func set_allowed(value: bool) -> void:
	allowed = value

## --- 内部処理 --------------------------------------------------------------

func _flush_and_emit() -> void:
	if _buffer.is_empty():
		return

	# 古いものを先に処理したいので、そのまま順番に emit
	if debug_print:
		print("[InputBuffer] flushing ", _buffer.size(), " inputs")

	var now := Time.get_ticks_msec() / 1000.0
	# 念のため、ここでも有効期限切れを弾く(buffer_time が 0 の場合など)
	var flushed_any := false
	for bi in _buffer:
		if buffer_time > 0.0 and now - bi.time > buffer_time:
			continue
		flushed_any = true
		buffered_action_fired.emit(bi.action_name, bi.strength)

	if debug_print:
		if flushed_any:
			print("[InputBuffer] flush complete")
		else:
			print("[InputBuffer] nothing valid to flush (all expired)")

	_buffer.clear()

func _prune_expired_inputs() -> void:
	if buffer_time <= 0.0:
		# 0 のときは「その瞬間だけ有効」なので、ここでは何もしない
		return

	if _buffer.is_empty():
		return

	var now := Time.get_ticks_msec() / 1000.0
	var before_size := _buffer.size()

	# 有効期限切れのものだけ除外
	_buffer = _buffer.filter(func(bi: BufferedInput) -> bool:
		return now - bi.time <= buffer_time
	)

	if debug_print and before_size != _buffer.size():
		print("[InputBuffer] pruned expired inputs: ", before_size, " -> ", _buffer.size())

使い方の手順

ここからは、実際にプレイヤーのコンボ攻撃に組み込む例で説明します。

シーン構成例(プレイヤー)

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── InputBuffer (Node)  ← このコンポーネントを追加

手順①: コンポーネントをシーンにアタッチする

  1. 上記のように、Player シーンの子として Node を追加し、スクリプトに InputBuffer.gd を設定します。
  2. インスペクタで以下を設定します:
    • watched_actions: ["attack_light", "attack_heavy"] など
    • buffer_time: 0.1〜0.2秒くらいから試すと良いです
    • flush_mode: とりあえず ON_ALLOWED_ENABLED のままでOK
    • max_buffer_size: 1(常に最新の入力だけ)か 4(連打も拾う)など好みで

手順②: プレイヤー側で「攻撃中は入力禁止」にする

プレイヤー本体のスクリプトで、攻撃開始・終了時に allowed を切り替えます。


extends CharacterBody2D

@onready var input_buffer: InputBuffer = $InputBuffer
@onready var anim: AnimationPlayer = $AnimationPlayer

var is_attacking: bool = false

func _ready() -> void:
	# バッファから入力が吐き出されたときのシグナルを受け取る
	input_buffer.buffered_action_fired.connect(_on_buffered_action_fired)

func _physics_process(_delta: float) -> void:
	# 攻撃中でないときだけ、通常の入力処理
	if not is_attacking:
		_handle_movement_input()
		_handle_attack_input()

func _handle_movement_input() -> void:
	# 左右移動など、今回はざっくり
	var dir := Input.get_axis("move_left", "move_right")
	velocity.x = dir * 200.0
	move_and_slide()

func _handle_attack_input() -> void:
	# 攻撃受付中のときは、InputBuffer ではなく直接 Input を見てもOK
	if Input.is_action_just_pressed("attack_light"):
		_start_attack("light")
	elif Input.is_action_just_pressed("attack_heavy"):
		_start_attack("heavy")

func _start_attack(kind: String) -> void:
	is_attacking = true
	# 攻撃中は InputBuffer にバッファしてもらうため、allowed を false に
	input_buffer.set_allowed(false)

	match kind:
		"light":
			anim.play("attack_light")
		"heavy":
			anim.play("attack_heavy")

# AnimationPlayer のアニメーション終了シグナルから呼ばれる想定
func _on_AnimationPlayer_animation_finished(anim_name: StringName) -> void:
	if anim_name.begins_with("attack_"):
		is_attacking = false
		# 攻撃終了 → 入力受付を再開
		input_buffer.set_allowed(true)

func _on_buffered_action_fired(action_name: StringName, strength: float) -> void:
	# 攻撃終了直後に、バッファされていた攻撃をここで処理
	# 例: コンボ判定や次の攻撃開始など
	if not is_attacking:
		if action_name == "attack_light":
			_start_attack("light")
		elif action_name == "attack_heavy":
			_start_attack("heavy")

ポイント:

  • 攻撃中は allowed = false にして、攻撃終了時に true に戻しています。
  • flush_mode = ON_ALLOWED_ENABLED なので、allowedtrue にした瞬間にバッファが自動的に吐き出され、_on_buffered_action_fired が呼ばれます。
  • これで「攻撃モーション中に押していたボタン」が、終了直後にコンボとして発動するようになります。

手順③: AnimationPlayer からのイベント連携

上の例では AnimationPlayer.animation_finished を使いましたが、

  • アニメーションの「ヒットストップ明け」
  • 「コンボ受付開始フレーム」

など、もう少し細かく制御したい場合は、アニメーションの アニメーションイベント(Call Method Track) から呼ぶのがおすすめです。


func _on_attack_combo_window_open() -> void:
	# コンボ受付開始(例: 攻撃後半のみ次入力を受け付ける)
	input_buffer.set_allowed(true)

func _on_attack_combo_window_close() -> void:
	# コンボ受付終了(以降はバッファへ)
	input_buffer.set_allowed(false)

このように、アニメーションのタイミングと allowed の切り替えを連携させると、かなり細かいコンボ受付調整ができます。

手順④: 敵や動く床にも流用してみる

コンポーネント化の良いところは、「プレイヤー専用ロジック」にならないことです。例えば:

  • 敵AI:
    • 行動中に「次の行動指示」をバッファしておき、行動終了直後に実行
    • AI側は「行動キュー」のように buffered_action_fired を使える
  • 動く床:
    • プレイヤーがスイッチを連打しても、床側は「移動中は入力をバッファ」にしておき、移動完了後に次の移動を開始する

敵のシーン構成例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── InputBuffer (Node)  ← 同じコンポーネントを再利用

メリットと応用

この InputBuffer コンポーネントを導入すると、かなり嬉しいポイントが多いです。

  • シーン構造がシンプルになる
    「入力バッファ」「コンボ受付」のロジックを、プレイヤーや敵のスクリプトから切り離せます。
    深い継承ツリーや巨大な状態マシンの中に押し込まなくて済むので、見通しが良くなります。
  • どのキャラでも同じ挙動を共有できる
    プレイヤー / 敵 / ボス / トレーニングダミーなど、全員に同じ InputBuffer をアタッチするだけで、「入力猶予」の仕様を統一できます。
  • パラメータ調整が楽
    buffer_timemax_buffer_size をインスペクタから変えるだけで、「このボスだけ入力猶予をシビアにする」などの調整が簡単です。
    コードを書き換えずに、レベルデザイン側でバランス調整できます。
  • テスト・デバッグがしやすい
    debug_print をオンにしておけば、「いまどの入力がバッファされているか」がログで確認できます。
    さらに get_buffered_actions() を使えば、デバッグ用UIに表示することも可能です。

改造案: 「方向キー+攻撃」もまとめて扱う

応用として、「方向+攻撃」でコマンド技を出す場合にも対応したくなるかもしれません。
その場合は、BufferedInput に「方向」情報を持たせるのが手っ取り早いです。


## 方向キーの状態を一緒に記録する例
func _handle_action_pressed(action_name: StringName, strength: float) -> void:
	var now := Time.get_ticks_msec() / 1000.0

	# 方向入力を一緒に記録
	var dir_x := Input.get_axis("move_left", "move_right")
	var dir_y := Input.get_axis("move_up", "move_down")

	# BufferedInput に dir_x, dir_y を追加しておき、
	# buffered_action_fired シグナルの引数にも渡すように改造する
	# (この例では概念だけ示しています)
	var input := BufferedInputWithDir.new(action_name, strength, now, dir_x, dir_y)

	# あとは既存のバッファ処理と同様に扱える

こうして「攻撃ボタンが押された瞬間の方向」も一緒に保存しておくと、

  • 上+攻撃 → 対空技
  • 下+攻撃 → 足払い

といったコマンド技も、同じ InputBuffer コンポーネントの延長で扱えるようになります。


継承ベースでガチガチに固めるよりも、「入力バッファ」という1つの責務に絞ったコンポーネントに分離しておくと、あとからの改造や流用がかなり楽になります。
ぜひ、自分のプロジェクト用に少しカスタマイズしつつ、コンポーネント指向な Godot 開発を楽しんでみてください。