Godotでキーコンフィグをちゃんと実装しようとすると、思ったより面倒なんですよね。

  • InputMap はプロジェクト設定(Project Settings)で固定しがち
  • UI から「今押したキーをこのアクションに割り当て直す」仕組みを自前で書く必要がある
  • シーンごとにバラバラに処理を書くと、あとから管理不能になる

さらに、ありがちな実装としては「Player.gd の中にキーコンフィグ処理も全部ねじ込む」というパターンがあります。すると、

  • Player のスクリプトが肥大化してテストしづらい
  • メニューシーンやオプションシーンからも Player のスクリプトに依存してしまう
  • 別プロジェクトに持っていきにくい

そこで今回は、「どのシーンにもポン付けできる汎用コンポーネント」として、InputMap のアクションに対してプレイヤーが押したキーを割り当て直す KeyConfig コンポーネント を作ってみましょう。継承ではなく「合成(Composition)」で、キーコンフィグ機能をあと付けするイメージですね。

【Godot 4】押したキーをそのまま割り当て!「KeyConfig」コンポーネント

このコンポーネントは、

  • 指定したアクション名(例: "move_left", "jump")に対して
  • ユーザーが次に押したキー(キーボード or ゲームパッドボタン)を検出し
  • その入力を InputMap に登録し直す

という処理を、どのシーンからでも簡単に使えるようにしたものです。


フルコード:KeyConfig.gd


extends Node
class_name KeyConfig
## 汎用キーコンフィグ用コンポーネント
## - InputMap のアクションに対して、ユーザーが押したキーを割り当て直す
## - UI(ボタンなど)から「このアクションを設定したい」と呼び出して使う

signal waiting_started(action_name: String)
signal waiting_canceled(action_name: String)
signal key_assigned(action_name: String, event: InputEvent)
signal key_assignment_failed(action_name: String, reason: String)

## 設定を JSON で保存する簡易オプション
@export var enable_save: bool = true
## 設定ファイルのパス(user:// はユーザーデータフォルダ)
@export var save_path: String = "user://key_config.json"

## 設定変更時に、既存の割り当てをすべて消してから新しいキーだけを登録するかどうか
@export var clear_existing_on_assign: bool = true

## どのデバイス入力を受け付けるかのフラグ
@export var accept_keyboard: bool = true
@export var accept_mouse: bool = false
@export var accept_joypad_button: bool = true
@export var accept_joypad_axis: bool = false

## 連打による誤登録を防ぐための、入力受付までの待ち時間(秒)
@export var wait_delay_sec: float = 0.1

## 現在「どのアクションの割り当て待ち」をしているか
var _waiting_action: StringName = ""
## 待ち状態に入った時間
var _wait_start_time: float = 0.0
## 内部的に使用する一時フラグ
var _is_waiting: bool = false


func _ready() -> void:
    # 起動時に保存ファイルがあれば読み込む
    if enable_save:
        _load_from_file()


func _unhandled_input(event: InputEvent) -> void:
    # 何も待っていないときはスルー
    if not _is_waiting:
        return

    # wait_delay_sec 経過前は誤入力とみなしてスルー
    if Time.get_ticks_msec() / 1000.0 - _wait_start_time < wait_delay_sec:
        return

    # 押された瞬間のイベントだけを拾う
    if not _is_press_event(event):
        return

    # 許可されている入力種別かチェック
    if not _is_accepted_event(event):
        key_assignment_failed.emit(str(_waiting_action), "unsupported_input_type")
        return

    # 実際に InputMap に登録する
    _assign_event_to_action(_waiting_action, event)

    # 保存設定が有効ならファイルに書き出し
    if enable_save:
        _save_to_file()

    key_assigned.emit(str(_waiting_action), event)
    _clear_waiting_state()


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

## 指定したアクション名に対する「次に押したキー」を登録するモードに入る
func start_waiting(action_name: StringName) -> void:
    _waiting_action = action_name
    _wait_start_time = Time.get_ticks_msec() / 1000.0
    _is_waiting = true
    waiting_started.emit(str(_waiting_action))

## 待ち状態をキャンセルする(例:キャンセルボタンを押したときなど)
func cancel_waiting() -> void:
    if not _is_waiting:
        return
    var tmp := _waiting_action
    _clear_waiting_state()
    waiting_canceled.emit(str(tmp))

## 現在待ち状態かどうか
func is_waiting() -> bool:
    return _is_waiting

## 指定アクションに現在登録されている InputEvent の配列を取得
func get_events_for_action(action_name: StringName) -> Array[InputEvent]:
    return InputMap.action_get_events(action_name)

## 指定アクションの割り当てをすべて削除
func clear_action(action_name: StringName) -> void:
    InputMap.action_erase_events(action_name)
    if enable_save:
        _save_to_file()

## すべてのアクションの割り当てを削除(リセット用)
func clear_all_actions() -> void:
    for action_name in InputMap.get_actions():
        InputMap.action_erase_events(action_name)
    if enable_save:
        _save_to_file()

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

func _clear_waiting_state() -> void:
    _waiting_action = ""
    _is_waiting = false
    _wait_start_time = 0.0


func _is_press_event(event: InputEvent) -> bool:
    # 「押した瞬間」のイベントだけを拾いたいので is_pressed() を利用
    if event is InputEventKey:
        return event.pressed and not event.echo
    if event is InputEventMouseButton:
        return event.pressed
    if event is InputEventJoypadButton:
        return event.pressed
    if event is InputEventJoypadMotion:
        # 軸は「ある程度以上倒した瞬間」を押下とみなす
        return abs(event.axis_value) >= 0.5
    return false


func _is_accepted_event(event: InputEvent) -> bool:
    if event is InputEventKey:
        return accept_keyboard
    if event is InputEventMouseButton:
        return accept_mouse
    if event is InputEventJoypadButton:
        return accept_joypad_button
    if event is InputEventJoypadMotion:
        return accept_joypad_axis
    return false


func _assign_event_to_action(action_name: StringName, event: InputEvent) -> void:
    if clear_existing_on_assign:
        InputMap.action_erase_events(action_name)

    # 既に同じイベントが登録されている場合は何もしない
    for existing in InputMap.action_get_events(action_name):
        if existing.is_match(event, true):
            return

    InputMap.action_add_event(action_name, event)


# --- 保存/読み込み処理 -----------------------------------------------------

## 現在の InputMap を JSON としてファイルに保存する
func _save_to_file() -> void:
    var data: Dictionary = {}

    for action_name in InputMap.get_actions():
        var events: Array = []
        for e in InputMap.action_get_events(action_name):
            # InputEvent は Dictionary に変換してシリアライズする
            events.append(e.as_text())  # ここでは簡易的に文字列表現を保存
        data[action_name] = events

    var file := FileAccess.open(save_path, FileAccess.WRITE)
    if file == null:
        push_warning("KeyConfig: Failed to open save file: %s" % save_path)
        return

    file.store_string(JSON.stringify(data, "  "))


## 保存ファイルから InputMap を復元する
func _load_from_file() -> void:
    if not FileAccess.file_exists(save_path):
        return

    var file := FileAccess.open(save_path, FileAccess.READ)
    if file == null:
        push_warning("KeyConfig: Failed to open save file: %s" % save_path)
        return

    var text := file.get_as_text()
    var result := JSON.parse_string(text)
    if typeof(result) != TYPE_DICTIONARY:
        push_warning("KeyConfig: Invalid JSON format in %s" % save_path)
        return

    var data: Dictionary = result

    # 既存を全消ししてから復元
    for action_name in InputMap.get_actions():
        InputMap.action_erase_events(action_name)

    for action_name in data.keys():
        var events: Array = data[action_name]
        for event_text in events:
            if typeof(event_text) != TYPE_STRING:
                continue
            var event := _event_from_text(event_text)
            if event:
                InputMap.action_add_event(action_name, event)


## as_text() で保存した文字列から InputEvent を復元する簡易版
## ※ Godot 公式の完全な逆変換はないので、よく使うキー/ボタンを中心に対応
func _event_from_text(text: String) -> InputEvent:
    # 例: "InputEventKey : keycode=KEY_A, physical_keycode=KEY_A"
    if text.begins_with("InputEventKey"):
        var ev := InputEventKey.new()
        # 超簡易パース(本気でやるなら正規表現などを使う)
        var keycode_str := _extract_after(text, "keycode=")
        if keycode_str != "":
            var code := @GDScript.NameConstant(keycode_str) if keycode_str.begins_with("KEY_") else 0
            ev.keycode = code
        return ev

    if text.begins_with("InputEventJoypadButton"):
        var evb := InputEventJoypadButton.new()
        var button_index_str := _extract_after(text, "button_index=")
        if button_index_str != "":
            evb.button_index = int(button_index_str)
        return evb

    # 必要に応じてマウスや軸も拡張可能
    return null


func _extract_after(text: String, key: String) -> String:
    var idx := text.find(key)
    if idx == -1:
        return ""
    var sub := text.substr(idx + key.length())
    var comma := sub.find(",")
    if comma != -1:
        sub = sub.substr(0, comma)
    return sub.strip_edges()

上のコードはそのまま KeyConfig.gd として保存して OK です。最低限の JSON 保存・読み込みも含めてあるので、「一度設定したキーを次回起動時にも復元する」ところまでカバーしています。


使い方の手順

① コンポーネントをシーンに置く

まずはどこかのシーンに KeyConfig ノードを 1 個置きましょう。ゲーム全体で 1 つのオプションメニューを使う場合は、オプションシーンに置くのが分かりやすいです。

OptionsMenu (Control)
 ├── VBoxContainer
 │    ├── Button ("Jump"再設定ボタン)
 │    └── Button ("Shoot"再設定ボタン)
 └── KeyConfig (Node)
  • KeyConfig ノードに、上記の KeyConfig.gd をアタッチします。
  • インスペクタから enable_savesave_path を好みに合わせて設定しましょう。

② ボタンから KeyConfig を呼び出す

例えば「Jump のキーを変更する」ボタンに次のようなスクリプトを付けます。


extends Button

@onready var key_config: KeyConfig = get_node("../KeyConfig")

func _ready() -> void:
    pressed.connect(_on_pressed)
    # Signal をつないで UI 表示も更新できる
    key_config.waiting_started.connect(_on_waiting_started)
    key_config.key_assigned.connect(_on_key_assigned)

func _on_pressed() -> void:
    # "jump" アクションのキー設定を開始
    key_config.start_waiting("jump")

func _on_waiting_started(action_name: String) -> void:
    if action_name == "jump":
        text = "Press a key..."

func _on_key_assigned(action_name: String, event: InputEvent) -> void:
    if action_name == "jump":
        text = "Jump: " + event.as_text()

これで、ボタンをクリックしたあとに押したキー(またはゲームパッドボタン)が "jump" アクションに割り当てられます。

③ プレイヤー側は InputMap をそのまま使うだけ

プレイヤーのスクリプトは、特にキーコンフィグを意識する必要はありません。普通に Input.is_action_pressed() を使うだけです。


extends CharacterBody2D

@export var speed: float = 200.0

func _physics_process(delta: float) -> void:
    var dir := Vector2.ZERO

    if Input.is_action_pressed("move_left"):
        dir.x -= 1.0
    if Input.is_action_pressed("move_right"):
        dir.x += 1.0

    if Input.is_action_just_pressed("jump"):
        _jump()

    velocity.x = dir.x * speed
    move_and_slide()

func _jump() -> void:
    # ジャンプ処理
    pass

キーコンフィグで "jump" に何を割り当てようが、プレイヤー側は同じコードで動き続けます。まさに「合成による後付け機能」ですね。

④ 別シーンでもそのまま再利用する

コンポーネント化しているので、例えばタイトル画面に簡易オプションを置きたい場合も、同じ KeyConfig をペタっと貼るだけで済みます。

TitleScreen (Control)
 ├── StartButton
 ├── OptionsButton
 └── KeyConfig (Node)

Options シーンを開いたときに、そこにある KeyConfig が JSON を読んでくれるので、どのシーンからでも同じ設定を共有できます。


メリットと応用

  • シーン構造がスッキリ
    プレイヤーや UI のスクリプトにキーコンフィグロジックを埋めこまず、KeyConfig ノードに集約できます。
  • 使い回しが簡単
    別プロジェクトにキーコンフィグ機能を持っていきたいときも、KeyConfig.gd とシーンに置くノード 1 個で完結します。
  • テストしやすい
    KeyConfig だけを単体で動かし、「このアクションにこのキーが入るか?」を確認できるので、バグ切り分けが楽になります。
  • 合成で拡張しやすい
    「ゲームパッドだけ別設定にしたい」「プレイヤー 1 / 2 で別の設定を持ちたい」なども、KeyConfig を複数置く or 派生バージョンを作るだけで対応できます。

例えば、「特定のアクションだけは上書き禁止」にしたいケースを考えてみましょう。ゲームの根幹に関わる "ui_cancel" などを守りたい場合ですね。そんなときは、次のような簡単な改造ができます。


## KeyConfig.gd に追加する例
@export var locked_actions: Array[StringName] = []

func start_waiting(action_name: StringName) -> void:
    if action_name in locked_actions:
        key_assignment_failed.emit(str(action_name), "locked_action")
        return
    _waiting_action = action_name
    _wait_start_time = Time.get_ticks_msec() / 1000.0
    _is_waiting = true
    waiting_started.emit(str(_waiting_action))

locked_actions"ui_cancel""ui_accept" を登録しておけば、それらは UI から変更できないようにできます。こういう「細かいルール」を後から足していけるのも、コンポーネント化の強みですね。

ぜひ、自分のプロジェクト用に少しずつカスタマイズしながら、「継承より合成」スタイルのキーコンフィグを育てていきましょう。