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)

手順①:コンポーネントをシーンに追加する

  1. 上の InputRemapper.gd をプロジェクトのどこか(例: res://components/InputRemapper.gd)に保存。
  2. Godot エディタで、OptionsMenu シーンを開く。
  3. + ノードを追加Node を選択し、InputRemapper.gd をスクリプトとしてアタッチ。
  4. インスペクタで 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)

このように、機能を足したくなったらコンポーネント側にメソッドを追加するだけで、各シーンには「どう使うか」だけを書いていくスタイルが維持できます。継承ツリーをいじるより、ずっと安全で見通しがいいですね。