Unityを触り始めた頃って、つい Update() の中に「移動」「ジャンプ」「攻撃」「UI操作」…と、あらゆる入力処理を書いてしまいがちですよね。
最初は動くので満足できるのですが、あとから

  • 「ジャンプボタンをAボタンからBボタンに変えたい」
  • 「キーボードとゲームパッド両方に対応したい」
  • 「オプション画面でボタン割り当てを変えたい」

といった要望が出てくると、巨大な Update() の中から条件分岐を探して書き換えるハメになり、すぐにカオス化します。

そこでこの記事では、「入力割り当て(リマップ)」だけを担当する小さなコンポーネントとして、
InputRemapper コンポーネントを用意して、ゲームパッドやキーボードの入力を検知して、ランタイムに「InputMap」を書き換える仕組みを作っていきます。

プレイヤーの移動や攻撃ロジックから「どのボタンが押されたか」という詳細を切り離し、
「アクション名 → 実際のキー/ボタン」の対応関係をこのコンポーネントに集約することで、
Godクラス化を防ぎつつ、オプション画面からのキー設定変更にも対応しやすくなります。

【Unity】ゲーム中にキーコンフィグ可能!「InputRemapper」コンポーネント

ここでは、Unity Input System(新Input System)を使った、
ゲーム中にキー/ボタンを押してもらって、その入力をアクションに割り当てる汎用的なコンポーネントを作ります。

  • 例: 「Jump」アクションを、今から押したボタンに割り当て直す
  • キーボードとゲームパッドの両方をスキャン
  • 割り当てた結果を InputActionAsset に反映

というところまでを、1つのコンポーネントに閉じ込めます。


フルコード:InputRemapper.cs


using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;          // 新Input System
using UnityEngine.InputSystem.Controls; // 各種コントロール型
using UnityEngine.InputSystem.Utilities;

namespace InputRemapSample
{
    /// <summary>
    /// ランタイムで入力の割り当て(リマップ)を行うコンポーネント。
    /// ・InputActionAsset から対象のアクションを取得
    /// ・ユーザーが押したキー/ボタンを検出して、そのバインディングを書き換える
    /// ・オプション画面などから呼び出して使う想定
    /// 
    /// このコンポーネントは「入力の再割り当て」だけを担当し、
    /// 実際のキャラクター操作ロジックは別コンポーネントに分離するのがオススメです。
    /// </summary>
    public class InputRemapper : MonoBehaviour
    {
        [Header("対象の InputActionAsset")]
        [Tooltip("InputActions(.inputactions) から生成したアセットを指定します。")]
        [SerializeField] private InputActionAsset inputActions;

        [Header("リマップ対象の Action 名")]
        [Tooltip("例: \"Player/Jump\" のように、ActionMap名/Action名 で指定するのが分かりやすいです。")]
        [SerializeField] private string targetActionPath = "Player/Jump";

        [Header("待機時間設定")]
        [Tooltip("この秒数だけ入力を待ち、それでも押されなければタイムアウトとみなします。")]
        [SerializeField] private float listenTimeoutSeconds = 5f;

        [Header("デバッグログ出力")]
        [Tooltip("true にすると、割り当て結果などを Debug.Log で出力します。")]
        [SerializeField] private bool verboseLogging = true;

        // 現在リマップ中かどうか
        private bool isListening;

        // 現在リマップ中のコルーチン参照
        private Coroutine listeningCoroutine;

        // コールバック: リマップ成功時
        public event Action<InputAction, InputBinding> OnRemapSucceeded;

        // コールバック: リマップ失敗(タイムアウトなど)
        public event Action<InputAction> OnRemapFailed;

        // コールバック: リマップ開始時
        public event Action<InputAction> OnRemapStarted;

        private void Awake()
        {
            if (inputActions == null)
            {
                Debug.LogWarning("[InputRemapper] InputActionAsset が設定されていません。");
            }
        }

        private void OnEnable()
        {
            // 念のため、アセット全体を有効化
            if (inputActions != null)
            {
                inputActions.Enable();
            }
        }

        private void OnDisable()
        {
            if (inputActions != null)
            {
                inputActions.Disable();
            }

            // 無効化時にはリスニングも停止
            if (isListening && listeningCoroutine != null)
            {
                StopCoroutine(listeningCoroutine);
                isListening = false;
            }
        }

        /// <summary>
        /// targetActionPath で指定されたアクションの、最初のバインディングをリマップします。
        /// UIボタンなどから直接呼び出す簡易版です。
        /// </summary>
        public void StartRemapForTargetAction()
        {
            if (string.IsNullOrEmpty(targetActionPath))
            {
                Debug.LogError("[InputRemapper] targetActionPath が空です。");
                return;
            }

            StartRemap(targetActionPath);
        }

        /// <summary>
        /// 指定されたパスのアクションに対して、ユーザー入力を待ち受けてリマップを開始します。
        /// 例: StartRemap(\"Player/Jump\");
        /// </summary>
        /// <param name="actionPath">\"ActionMap名/Action名\" 形式、または単純な Action 名</param>
        public void StartRemap(string actionPath)
        {
            if (inputActions == null)
            {
                Debug.LogError("[InputRemapper] InputActionAsset が設定されていません。");
                return;
            }

            if (isListening)
            {
                Debug.LogWarning("[InputRemapper] すでにリマップ待機中です。");
                return;
            }

            // ActionMap名/Action名 形式に対応するため、スラッシュで分割
            InputAction targetAction = FindActionByPath(actionPath);
            if (targetAction == null)
            {
                Debug.LogError($"[InputRemapper] 指定された Action が見つかりません: {actionPath}");
                return;
            }

            // すでにあるバインディングを一旦無効にするかどうかは設計次第だが、
            // ここでは「最初のバインディングだけを差し替える」方針にする
            if (targetAction.bindings.Count == 0)
            {
                // バインディングが存在しない場合は、空のバインディングを追加しておく
                targetAction.AddBinding("");
            }

            listeningCoroutine = StartCoroutine(ListenAndRemapCoroutine(targetAction));
        }

        /// <summary>
        /// ActionMap名/Action名 形式、または単純な Action 名から InputAction を検索するヘルパー。
        /// </summary>
        private InputAction FindActionByPath(string actionPath)
        {
            if (actionPath.Contains("/"))
            {
                // "MapName/ActionName" 形式
                string[] parts = actionPath.Split('/');
                if (parts.Length != 2)
                {
                    Debug.LogError($"[InputRemapper] actionPath の形式が不正です: {actionPath}");
                    return null;
                }

                string mapName = parts[0];
                string actionName = parts[1];

                var map = inputActions.FindActionMap(mapName, throwIfNotFound: false);
                if (map == null)
                {
                    Debug.LogError($"[InputRemapper] ActionMap が見つかりません: {mapName}");
                    return null;
                }

                return map.FindAction(actionName, throwIfNotFound: false);
            }
            else
            {
                // 単純な Action 名として検索
                return inputActions.FindAction(actionPath, throwIfNotFound: false);
            }
        }

        /// <summary>
        /// 実際にユーザーの入力を待ち受けて、最初に検出したボタン/キーをバインディングとして設定するコルーチン。
        /// </summary>
        private IEnumerator ListenAndRemapCoroutine(InputAction targetAction)
        {
            isListening = true;

            // リマップ開始通知
            OnRemapStarted?.Invoke(targetAction);
            if (verboseLogging)
            {
                Debug.Log($"[InputRemapper] リマップ開始: {targetAction.name}");
            }

            float startTime = Time.unscaledTime;

            // どのデバイスからの入力も受け付けるため、すべての InputDevice を対象にする
            // ここではボタン系(Key, ButtonControl)のみを対象とし、スティックなどは無視する簡易実装にしている
            while (Time.unscaledTime - startTime < listenTimeoutSeconds)
            {
                // どのキー/ボタンが押されたかをチェック
                InputControl pressedControl = TryGetFirstPressedControl();
                if (pressedControl != null)
                {
                    // 押されたコントロールからパスを取得
                    string controlPath = pressedControl.path;

                    // 最初のバインディングを書き換える
                    int bindingIndex = 0;
                    var binding = targetAction.bindings[bindingIndex];

                    // 既存のバインディングのグループ(キーボード用、ゲームパッド用など)は維持したいので、
                    // groups だけは引き継ぎ、path だけを差し替える
                    var newBinding = new InputBinding
                    {
                        path = controlPath,
                        interactions = binding.interactions,
                        processors = binding.processors,
                        groups = binding.groups,
                        name = binding.name
                    };

                    targetAction.ApplyBindingOverride(bindingIndex, newBinding);

                    if (verboseLogging)
                    {
                        Debug.Log($"[InputRemapper] リマップ成功: {targetAction.name} => {controlPath}");
                    }

                    OnRemapSucceeded?.Invoke(targetAction, newBinding);

                    isListening = false;
                    listeningCoroutine = null;
                    yield break;
                }

                yield return null;
            }

            // タイムアウト
            if (verboseLogging)
            {
                Debug.LogWarning($"[InputRemapper] リマップ失敗(タイムアウト): {targetAction.name}");
            }

            OnRemapFailed?.Invoke(targetAction);

            isListening = false;
            listeningCoroutine = null;
        }

        /// <summary>
        /// すべてのデバイスを走査して、最初に押されているボタン/キー系コントロールを返します。
        /// スティックやトリガーのアナログ値はここでは無視しています。
        /// </summary>
        private InputControl TryGetFirstPressedControl()
        {
            // 有効なすべての InputDevice を走査
            foreach (var device in InputSystem.devices)
            {
                // 例外的なデバイス(Pointer, Touchなど)は今回はスキップしてもよい
                if (!device.enabled)
                {
                    continue;
                }

                // 各デバイス内のコントロールを走査
                foreach (var control in device.allControls)
                {
                    // 無効なコントロールはスキップ
                    if (!control.enabled)
                    {
                        continue;
                    }

                    // ボタン系のコントロールに限定する
                    // KeyControl, ButtonControl などを対象にする
                    if (control is KeyControl keyControl)
                    {
                        if (keyControl.wasPressedThisFrame)
                        {
                            return keyControl;
                        }
                    }
                    else if (control is ButtonControl buttonControl)
                    {
                        if (buttonControl.wasPressedThisFrame)
                        {
                            return buttonControl;
                        }
                    }
                }
            }

            return null;
        }

        /// <summary>
        /// 指定した Action のバインディングオーバーライドをすべてクリアします。
        /// これにより、InputActionAsset に設定してあるデフォルトのバインディングに戻ります。
        /// </summary>
        /// <param name="actionPath">\"ActionMap名/Action名\" または Action 名</param>
        public void ResetBindingOverrides(string actionPath)
        {
            if (inputActions == null)
            {
                Debug.LogError("[InputRemapper] InputActionAsset が設定されていません。");
                return;
            }

            var action = FindActionByPath(actionPath);
            if (action == null)
            {
                Debug.LogError($"[InputRemapper] 指定された Action が見つかりません: {actionPath}");
                return;
            }

            action.RemoveAllBindingOverrides();

            if (verboseLogging)
            {
                Debug.Log($"[InputRemapper] バインディングオーバーライドをリセット: {action.name}");
            }
        }

        /// <summary>
        /// targetActionPath で指定されたアクションのバインディングをリセットする簡易版。
        /// UIボタンから直接呼び出しやすいように用意しています。
        /// </summary>
        public void ResetTargetActionBinding()
        {
            if (string.IsNullOrEmpty(targetActionPath))
            {
                Debug.LogError("[InputRemapper] targetActionPath が空です。");
                return;
            }

            ResetBindingOverrides(targetActionPath);
        }

        /// <summary>
        /// 現在リマップ中かどうかを返します。
        /// 他のUIから「今は別のボタンを押さないでください」と表示したいときなどに使えます。
        /// </summary>
        public bool IsListening()
        {
            return isListening;
        }
    }
}

使い方の手順

ここでは、プレイヤーの「ジャンプ」ボタンをゲーム中に変更できる例で説明します。
Unity6 で新 Input System を前提にしています。

  1. InputActionAsset(.inputactions)を用意する

    • Project ウィンドウで右クリック → Create > Input Actions から PlayerInputActions などを作成
    • ダブルクリックして Input Actions エディタを開く
    • Player という Action Map を作り、Jump という Action を追加
    • Jump にデフォルトのバインディング(例: Keyboard: Space, Gamepad: buttonSouth)を設定
    • 保存して閉じる
  2. シーンに InputRemapper を配置する

    • 空の GameObject を作成(例: InputSystemRoot
    • InputRemapper コンポーネントをアタッチ
    • InputActions フィールドに、さきほど作った PlayerInputActions アセットをドラッグ&ドロップ
    • Target Action Path には Player/Jump と入力
  3. UIボタンからリマップを開始できるようにする

    • Canvas を作り、ボタン(例: Button_RemapJump)を配置
    • ボタンの OnClick に InputRemapper をドラッグ
    • 関数一覧から InputRemapper.StartRemapForTargetAction() を選択
    • ゲーム実行中にこのボタンを押すと、「次に押したキー/ボタン」が Jump に割り当てられます
  4. プレイヤー側では「Jump アクション」だけを見る

    プレイヤー制御用スクリプト(例: PlayerJumpController)では、
    どのキー/ボタンが Jump に割り当てられているかは気にせず
    ひたすら Jump アクションの発火だけを見るようにします。

    
    using UnityEngine;
    using UnityEngine.InputSystem;
    
    public class PlayerJumpController : MonoBehaviour
    {
        [SerializeField] private InputActionAsset inputActions;
        [SerializeField] private float jumpForce = 5f;
        [SerializeField] private Rigidbody rb;
    
        private InputAction jumpAction;
    
        private void Awake()
        {
            // "Player/Jump" を取得
            jumpAction = inputActions.FindAction("Player/Jump", throwIfNotFound: true);
        }
    
        private void OnEnable()
        {
            inputActions.Enable();
            jumpAction.performed += OnJumpPerformed;
        }
    
        private void OnDisable()
        {
            jumpAction.performed -= OnJumpPerformed;
            inputActions.Disable();
        }
    
        private void OnJumpPerformed(InputAction.CallbackContext context)
        {
            // ここでは「ジャンプする」という責務だけを書く
            rb.AddForce(Vector3.up * jumpForce, ForceMode.VelocityChange);
        }
    }
    

    このように、PlayerJumpController は「Jump に何が割り当てられているか」を一切知りません。
    入力の割り当てはすべて InputRemapper に任せることで、責務をきれいに分離できます。

同じ要領で、敵AIの「行動切り替えテスト用キー」や、動く床のデバッグ用トグル入力なども、
入力アクションを分けておき、必要に応じて InputRemapper で好きなキーに割り当て直せます。


メリットと応用

InputRemapper コンポーネントを導入することで、次のようなメリットがあります。

  • 入力処理の責務分離
    プレイヤー制御、UI操作、デバッグ入力などから「キー割り当て変更ロジック」を切り離せます。
    それぞれのコンポーネントは「アクションが来たら何をするか」だけに集中できます。
  • プレハブ管理がシンプルに
    プレイヤープレハブは「Jump アクションを使う」ことだけを前提に作り、
    どのボタンが Jump なのかはシーン側の InputRemapper に任せられます。
    これにより、ゲームごとにキー配置が違ってもプレハブを使い回しやすくなります。
  • レベルデザイン時のテストが楽
    遠い距離のジャンプテストや、特定アクションの連打テストなど、
    「このシーンだけ特殊なキーで操作したい」というニーズが出たときに、
    シーン専用の InputRemapper を置くだけで対応できます。
  • オプション画面からのキーコンフィグに対応しやすい
    UI から StartRemap() を呼ぶだけで、
    ユーザーに好きなボタンを押してもらう「キーコンフィグ画面」を簡単に実装できます。

さらに、応用としては:

  • リマップ結果を JSON などで保存し、次回起動時に復元する
  • 特定のデバイス(キーボードのみ、ゲームパッドのみ)に限定してスキャンする
  • スティック方向(例: 上入力)をバインディングに使えるように拡張する

といった拡張も考えられます。

改造案:最後に押されたキーの表示テキストを更新する

例えば、UI テキストに「現在のジャンプボタン: Space」のように表示したい場合、
OnRemapSucceeded イベントを利用して、次のような関数を追加できます。


using TMPro;
using UnityEngine;

public class RemapResultLabel : MonoBehaviour
{
    [SerializeField] private InputRemapSample.InputRemapper inputRemapper;
    [SerializeField] private TextMeshProUGUI label;

    private void OnEnable()
    {
        if (inputRemapper != null)
        {
            inputRemapper.OnRemapSucceeded += HandleRemapSucceeded;
        }
    }

    private void OnDisable()
    {
        if (inputRemapper != null)
        {
            inputRemapper.OnRemapSucceeded -= HandleRemapSucceeded;
        }
    }

    // リマップ成功時にラベルを書き換える
    private void HandleRemapSucceeded(InputAction action, InputBinding binding)
    {
        label.text = $"{action.name}: {binding.path}";
    }
}

このように、小さなコンポーネントを組み合わせていくことで、
巨大な God クラスに頼らず、見通しのよい入力システムを構築していきましょう。