アクションゲームを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) ← このコンポーネントを追加
手順①: コンポーネントをシーンにアタッチする
- 上記のように、
Playerシーンの子としてNodeを追加し、スクリプトにInputBuffer.gdを設定します。 - インスペクタで以下を設定します:
watched_actions:["attack_light", "attack_heavy"]などbuffer_time: 0.1〜0.2秒くらいから試すと良いですflush_mode: とりあえずON_ALLOWED_ENABLEDのままでOKmax_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なので、allowedをtrueにした瞬間にバッファが自動的に吐き出され、_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_timeやmax_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 開発を楽しんでみてください。
