Godot 4で入力まわりを作り込んでいくと、だいたいこんな悩みにぶつかりますよね。
- 設定画面で「キーコンフィグ」を作りたいけど、
InputMapをどう書き換えればいいか分かりづらい - ゲームパッドとキーボードの両対応をしたいが、「次に押されたボタン」をきれいに拾う仕組みが面倒
- プレイヤー、メニュー、ミニゲームなど、シーンごとに似たようなキーコンフィグ処理をコピペしてしまう
Godot標準のやり方でも、InputMap.add_action() や InputMap.action_add_event() を直接叩けば実現できますが、
- どこで「入力待ち」状態にするか
- どのアクションを上書きするか
- 既存のバインドをどう扱うか(上書き・追加・削除)
といったロジックが、あちこちのシーンにバラけがちです。継承ベースで SettingsMenuBase みたいなクラスを作ると、さらに階層が深くなって保守しづらくなりますね。
そこで今回は、「入力割当」だけを担当する独立コンポーネント InputRemapper を用意して、どのシーンにもポン付けできる形にしてみましょう。ノード階層はシンプルなまま、コンポーネント指向でキーコンフィグ機能を合成していくスタイルです。
【Godot 4】押したボタンをそのまま割り当て!「InputRemapper」コンポーネント
このコンポーネントは、
- 「次に押されたキー or ボタン」を検知して
- 指定した
InputMapのアクションに、その入力を登録・上書き - UI側とはシグナルでやり取り(「待機開始」「割当完了」「キャンセル」など)
という、キーコンフィグのコア部分を担当します。
プレイヤーシーンでも、オプションメニューシーンでも、どこにでもアタッチして使い回せるのがポイントですね。
InputRemapper.gd フルコード
extends Node
class_name InputRemapper
## 入力割当コンポーネント
## - 「次に押されたボタン」を拾って InputMap を書き換える
## - UI とはシグナルで連携する
## --- 設定パラメータ ---
## どの InputMap アクションを対象にするか
## 例: "move_left", "jump", "attack"
@export var target_action: StringName = &"jump"
## 既存のバインドをどう扱うか
## true なら、割当時に既存の入力を全削除してから 1 つだけ登録
## false なら、既存のバインドに「追加」する(複数入力で同じアクションを発火させたい場合)
@export var overwrite_existing: bool = true
## どのデバイスの入力を許可するか
@export var accept_keyboard: bool = true
@export var accept_mouse: bool = false
@export var accept_gamepad: bool = true
## 入力待ち状態のタイムアウト(秒)
## 0 以下なら無制限に待機
@export_range(0.0, 60.0, 0.1, "or_greater") var wait_timeout: float = 10.0
## どの程度の入力を「有効」とみなすか(アナログスティック用)
@export_range(0.1, 1.0, 0.05) var axis_threshold: float = 0.5
## 割当中に無視したいキー(例: ESC, F1 など)
@export var ignore_keys: Array[Key] = [Key.ESCAPE]
## 割当中に無視したいゲームパッドボタン
@export var ignore_joy_buttons: Array[JoyButton] = []
## --- シグナル定義 ---
## 入力待ちが開始されたとき
signal remap_started(action_name: StringName)
## 入力待ちがタイムアウト、またはキャンセルされたとき
signal remap_canceled(action_name: StringName, reason: String)
## 入力が割り当てられたとき
signal remap_completed(
action_name: StringName,
event: InputEvent,
was_overwrite: bool
)
## --- 内部状態 ---
var _is_listening: bool = false
var _elapsed: float = 0.0
func _ready() -> void:
# 特に何もしないが、デバッグログを出しておく
print("[InputRemapper] Ready for action: ", target_action)
func _process(delta: float) -> void:
if not _is_listening:
return
if wait_timeout > 0.0:
_elapsed += delta
if _elapsed >= wait_timeout:
_cancel_remap("timeout")
func _unhandled_input(event: InputEvent) -> void:
# 入力待ちでなければスルー
if not _is_listening:
return
# UI で消費した入力は基本来ないが、念のため
if event.is_echo():
return
# 入力イベントをフィルタして「採用するもの」を決める
var chosen: InputEvent = _filter_event(event)
if chosen == null:
return
# 実際に InputMap を書き換える
_apply_mapping(chosen)
func start_remap(action_name: StringName = StringName()) -> void:
## 外部から呼び出して「入力待ち」を開始する
## action_name を渡せば target_action を上書きできる
if action_name != StringName():
target_action = action_name
if String(target_action) == "":
push_warning("[InputRemapper] target_action is empty. Aborting remap.")
return
_is_listening = true
_elapsed = 0.0
emit_signal("remap_started", target_action)
print("[InputRemapper] Start remap for action: ", target_action)
func cancel_remap(reason: String = "manual") -> void:
## 外部から明示的にキャンセルしたいとき用
if not _is_listening:
return
_cancel_remap(reason)
func _cancel_remap(reason: String) -> void:
_is_listening = false
_elapsed = 0.0
emit_signal("remap_canceled", target_action, reason)
print("[InputRemapper] Remap canceled for %s (%s)" % [target_action, reason])
func _filter_event(event: InputEvent) -> InputEvent:
## ここで「採用する入力」「捨てる入力」を決める
## 返り値が null のときは無視される
# キーボード
if accept_keyboard and event is InputEventKey:
var e := event as InputEventKey
if not e.pressed:
return null
if e.keycode in ignore_keys:
return null
return e
# マウスボタン(必要なら)
if accept_mouse and event is InputEventMouseButton:
var m := event as InputEventMouseButton
if not m.pressed:
return null
return m
# ゲームパッドボタン
if accept_gamepad and event is InputEventJoypadButton:
var jb := event as InputEventJoypadButton
if not jb.pressed:
return null
if jb.button_index in ignore_joy_buttons:
return null
return jb
# ゲームパッド軸(スティック)
if accept_gamepad and event is InputEventJoypadMotion:
var jm := event as InputEventJoypadMotion
# 軸の絶対値がしきい値を超えたら有効とみなす
if abs(jm.axis_value) < axis_threshold:
return null
# JoypadMotion はそのままだと左右/上下の判定が曖昧なので、
# 正方向・負方向で別アクションに割り当てたいケースもある。
# ここでは「そのままイベントを返す」シンプル実装。
return jm
return null
func _apply_mapping(event: InputEvent) -> void:
## 実際に InputMap を書き換える処理
_is_listening = false
_elapsed = 0.0
var existed := InputMap.has_action(target_action)
if not existed:
# アクションが存在しなければ追加する
InputMap.add_action(target_action)
print("[InputRemapper] Created new action: ", target_action)
if overwrite_existing:
# 既存のイベントをすべて削除してから 1 つだけ追加
var current_events := InputMap.action_get_events(target_action)
for e in current_events:
InputMap.action_erase_event(target_action, e)
InputMap.action_add_event(target_action, event)
print("[InputRemapper] Overwrote mapping for %s with %s" %
[target_action, event])
else:
# 既存に追加(重複チェックは簡易的に)
var should_add := true
for e in InputMap.action_get_events(target_action):
if e.as_text() == event.as_text():
should_add = false
break
if should_add:
InputMap.action_add_event(target_action, event)
print("[InputRemapper] Added mapping for %s: %s" %
[target_action, event])
else:
print("[InputRemapper] Same mapping already exists for %s" %
[target_action])
emit_signal("remap_completed", target_action, event, overwrite_existing)
使い方の手順
ここでは、典型的な「オプション画面のキーコンフィグ」での使い方を例に説明します。
プレイヤーシーンや敵シーンに直接アタッチして、「その場で入力を差し替える」こともできます。
シーン構成例:オプションメニューでキーコンフィグ
OptionsMenu (Control)
├── VBoxContainer
│ ├── HBoxContainer
│ │ ├── Label ("Jump")
│ │ └── Button ("割り当て")
│ └── HBoxContainer
│ ├── Label ("Attack")
│ └── Button ("割り当て")
└── InputRemapper (Node)
手順①:コンポーネントをシーンに追加する
- 上の
InputRemapper.gdをプロジェクトのどこか(例:res://components/InputRemapper.gd)に保存。 - Godot エディタで、
OptionsMenuシーンを開く。 + ノードを追加→Nodeを選択し、InputRemapper.gdをスクリプトとしてアタッチ。- インスペクタで
target_actionなどを初期設定しておく(後でコードからも変更可能)。
手順②:ボタンから remap 開始を呼び出す
ボタンを押したら「次に押されたキーを Jump に割り当てる」といった処理を書きます。
# OptionsMenu.gd (Control にアタッチ)
extends Control
@onready var remapper: InputRemapper = $InputRemapper
@onready var jump_button: Button = %JumpAssignButton
@onready var attack_button: Button = %AttackAssignButton
@onready var status_label: Label = %StatusLabel
func _ready() -> void:
# ボタンの押下で remap を開始
jump_button.pressed.connect(_on_jump_assign_pressed)
attack_button.pressed.connect(_on_attack_assign_pressed)
# Remapper のシグナルを受け取る
remapper.remap_started.connect(_on_remap_started)
remapper.remap_canceled.connect(_on_remap_canceled)
remapper.remap_completed.connect(_on_remap_completed)
func _on_jump_assign_pressed() -> void:
remapper.overwrite_existing = true
remapper.start_remap("jump")
func _on_attack_assign_pressed() -> void:
remapper.overwrite_existing = true
remapper.start_remap("attack")
func _on_remap_started(action_name: StringName) -> void:
status_label.text = "%s の入力を押してください..." % [action_name]
func _on_remap_canceled(action_name: StringName, reason: String) -> void:
status_label.text = "%s の割り当てはキャンセルされました (%s)" % [action_name, reason]
func _on_remap_completed(
action_name: StringName,
event: InputEvent,
was_overwrite: bool
) -> void:
status_label.text = "%s に %s を割り当てました" % [
action_name,
event.as_text()
]
これで、ボタンを押す → 「入力待ち」状態に入る → 何かキー/ボタンを押す → InputMap が書き換わる、という一連の流れが完成します。
手順③:プレイヤーシーンで同じコンポーネントを再利用する
例えば、プレイヤーシーンで「デバッグ用にその場でキーを差し替える」なんてことも簡単です。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── InputRemapper (Node)
# Player.gd (CharacterBody2D)
extends CharacterBody2D
@onready var remapper: InputRemapper = $InputRemapper
func _ready() -> void:
# ゲーム中に F2 を押したら「jump」再割当モードに入る、など
InputMap.add_action("debug_rebind_jump")
InputMap.action_add_event(
"debug_rebind_jump",
InputEventKey.new().set("keycode", Key.F2)
)
remapper.remap_completed.connect(_on_jump_remapped)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_rebind_jump"):
remapper.start_remap("jump")
func _on_jump_remapped(
action_name: StringName,
event: InputEvent,
was_overwrite: bool
) -> void:
print("Player jump remapped to: ", event.as_text())
プレイヤー側は「いつ remap を開始するか」だけを知っていればよく、細かい InputMap 書き換えロジックはコンポーネントに丸投げできます。
手順④:ゲームパッド限定・キーボード限定などのフィルタリング
ゲームパッド用の設定画面なら、accept_keyboard = false にしておくと、キーボード入力は無視されます。逆に PC 専用ゲームなら accept_gamepad = false にしておくとよいですね。
インスペクタからでも、コードからでも設定可能です:
func _ready() -> void:
var remapper := $InputRemapper
remapper.accept_keyboard = true
remapper.accept_gamepad = true
remapper.accept_mouse = false
remapper.wait_timeout = 5.0
メリットと応用
この InputRemapper コンポーネントを導入することで、次のようなメリットがあります。
- シーン構造がスッキリ:キーコンフィグのロジックを UI シーンやプレイヤースクリプトにベタ書きしなくて済みます。
- 使い回しが簡単:どのシーンにも
InputRemapperノードをアタッチして、シグナルを繋ぐだけで再利用可能。 - 合成(Composition)で拡張しやすい:入力割当の仕様変更(タイムアウト、無視キー、ゲームパッド対応など)はコンポーネント側だけ直せばよく、継承ツリーをいじる必要がありません。
- テストしやすい:
InputRemapper単体でシーンを作って、入力割当の挙動を確認する、といったユニットテスト的な運用がしやすいです。
改造案:直前のバインドに「アンドゥ」できるようにする
「間違って押しちゃった!」というときに、直前のバインドへ戻せると親切ですね。
簡易的に「最後のバインド」を保存しておき、undo_last() で戻せるようにする改造例です。
var _last_events: Array[InputEvent] = []
func _apply_mapping(event: InputEvent) -> void:
# 変更前の状態を保存しておく
_last_events = InputMap.action_get_events(target_action).duplicate()
_is_listening = false
_elapsed = 0.0
if not InputMap.has_action(target_action):
InputMap.add_action(target_action)
if overwrite_existing:
for e in InputMap.action_get_events(target_action):
InputMap.action_erase_event(target_action, e)
InputMap.action_add_event(target_action, event)
emit_signal("remap_completed", target_action, event, overwrite_existing)
func undo_last() -> void:
## 直前の割当を元に戻す
if _last_events.is_empty():
return
if not InputMap.has_action(target_action):
InputMap.add_action(target_action)
# 一旦全部消してから、前回の状態を復元
for e in InputMap.action_get_events(target_action):
InputMap.action_erase_event(target_action, e)
for e in _last_events:
InputMap.action_add_event(target_action, e)
print("[InputRemapper] Undo mapping for ", target_action)
このように、機能を足したくなったらコンポーネント側にメソッドを追加するだけで、各シーンには「どう使うか」だけを書いていくスタイルが維持できます。継承ツリーをいじるより、ずっと安全で見通しがいいですね。
