🎮

【Unity】Input Systemでガイド用のボタンアイコンを出す

2022/12/27に公開

Unityの (New) Input Systemでは、入力を抽象化して、マウスやキーボード、ゲームパッドなど様々なデバイスからの入力を統一的に扱うことができます。

今回は、Input Systemを使用しているプロジェクトでこういうガイド表示を出すやつをやります。Input Systemの基本的な使い方は省略して進めます。

方針

  • 指定したInputActionに対応する適切なアイコンを選択し表示する
  • アイコン表示にはTextMesh ProのSprite機能を使う
  • Scheme (マウス/キーボード操作、ゲームパッド操作)で表示を切り替える
  • 使用しているゲームパッドの種類(DUALSHOCK4, XInput, Switchプロコンなど)で表示を切り替える

登場人物

Input Systemで使用される概念等のおさらいです。

  • InputActionAsset
    • Input Systemで使用する入力を保存したアセット。
  • InputAction
    • 抽象化された単一のアクション。
  • InputDevice
    • 入力を行うデバイスを示すクラス。
  • InputControl
    • InputActionに対して実際に割り当てられる具体的なボタン等を示す。/XInputControllerWindows/leftStick/up/Keyboard/wなどのパスによって一意に識別できる
  • InputBinding
    • InputActionにInputControlを割り当てるために設定される情報。<Gamepad>/leftStick/up<Keyboard>/wなどのパスを使用して指定される

表示するアイコン名を列挙する

まずはInputActionから表示するアイコンの名前を決定する部分を書いていきます。

InputGuide.cs
using System.Linq;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.DualShock;
using UnityEngine.InputSystem.Switch;
using UnityEngine.InputSystem.XInput;

public class InputGuide : MonoBehaviour
{
    //InputActionReferenceはInputActionAsset内に存在する特定のInputActionへの参照をシリアライズできる
    [SerializeField] private InputActionReference actionReference = default;

    private void Start()
    {
        var action = actionReference.action;
        foreach (var binding in action.bindings)
        {
            //InputBindingのパスを取得する
            //effectivePathは、ランタイムのRebindingなどでoverridePathが設定されている場合も実効的なパスを取得できる
            var path = binding.effectivePath;

            //パスに合致するcontrolを取得する
            var matchedControls = action.controls.Where(control => InputControlPath.Matches(path, control));

            foreach (var control in matchedControls)
            {
                if (control is InputDevice) continue;
                
                // controlのpathは "/[デバイス名]/[パス]" のようフォーマットになっている
                // このデバイス名は"XInputControllerWindows" や "DualShock4GamepadHID"のように具体的すぎるので、
                // "XInputController"や"DualShockGamepad"のように、アイコンを表示するうえで適度に抽象化されたデバイス名に置き換える
                    
                var deviceIconGroup = GetDeviceIconGroup(control.device);
                if (string.IsNullOrEmpty(deviceIconGroup)) continue;

                var controlPathContent = control.path.Substring(control.device.name.Length + 2);

                Debug.Log($" - Icon name: {deviceIconGroup}-{controlPathContent}");
            }
        }
    }

    private static string GetDeviceIconGroup(InputDevice device)
    {
        return device switch
        {
            Keyboard => "Keyboard",
            Mouse => "Mouse",
            XInputController => "XInputController",
            DualShockGamepad => "DualShockGamepad",
            SwitchProControllerHID => "SwitchProController",
            _ => null
        };
    }
}


こちらのInputActionをactionReferenceにセットして実行すると、次のような結果が得られます。(結果は接続されているデバイスによって異なります)

ということで、上記のような<デバイス名>-<パス>のフォーマットでアイコン名を指定する方針で行きます。

アイコンの作成と表示

アイコン素材を作成します。
必要なアイコンを含んだスプライトシートを作成して、各スプライトにアイコン名をつけます。

作成したスプライトシートを右クリックしてTextMesh ProのSprite Assetを作成します。

先ほど作成したInputGuide.csをスプライト表示に対応させます。

InputGuide.cs

using System.Linq;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.DualShock;
using UnityEngine.InputSystem.Switch;
using UnityEngine.InputSystem.XInput;

public class InputGuide : MonoBehaviour
{
    //InputActionReferenceはInputActionAsset内に存在する特定のInputActionへの参照をシリアライズできる
    [SerializeField] private InputActionReference actionReference = default;

    [SerializeField] private TextMeshProUGUI text = default;

    private static readonly StringBuilder TempStringBuilder = new StringBuilder();

    private void OnEnable()
    {
        UpdateText();
    }

    private void UpdateText()
    {
        ProcessAction(actionReference.action);
    }

    private void ProcessAction(InputAction action)
    {
        TempStringBuilder.Clear();

        foreach (var binding in action.bindings)
        {
            //InputBindingのパスを取得する
            //effectivePathは、ランタイムのRebindingなどでoverridePathが設定されている場合も実効的なパスを取得できる
            var path = binding.effectivePath;

            //パスに合致するcontrolを取得する
            var matchedControls = action.controls.Where(control => InputControlPath.Matches(path, control));

            foreach (var control in matchedControls)
            {
                if (control is InputDevice) continue;

                // controlのpathは "/[デバイス名]/[パス]" のようフォーマットになっている
                // このデバイス名は"XInputControllerWindows" や "DualShock4GamepadHID"のように具体的すぎるので、
                // "XInputController"や"DualShockGamepad"のように、アイコンを表示するうえで適度に抽象化されたデバイス名に置き換える

                var deviceIconGroup = GetDeviceIconGroup(control.device);
                if (string.IsNullOrEmpty(deviceIconGroup)) continue;

                var controlPathContent = control.path.Substring(control.device.name.Length + 2);

                string iconName = $"{deviceIconGroup}-{controlPathContent}";

                var spriteIndex = text.spriteAsset.GetSpriteIndexFromName(iconName);

                if (spriteIndex >= 0)
                {
                    TempStringBuilder.Append("<sprite=");
                    TempStringBuilder.Append(spriteIndex);
                    TempStringBuilder.Append(">");
                }
            }
        }

        TempStringBuilder.Append(" ");
        TempStringBuilder.Append(action.name);

        text.text = TempStringBuilder.ToString();
    }


    private static string GetDeviceIconGroup(InputDevice device)
    {
        return device switch
        {
            Keyboard => "Keyboard",
            Mouse => "Mouse",
            XInputController => "XInputController",
            DualShockGamepad => "DualShockGamepad",
            SwitchProControllerHID => "SwitchProController",
            _ => null
        };
    }
}

こんな感じで設定します。

アイコンが表示されました。

Schemeによるフィルタ

Input Systemでは、各Bindingに「マウス/キーボード操作」「ゲームパッド操作」などのラベルを付けることができる、Control Schemeという仕組みがあります。これを利用して、マウス/キーボード操作を行っているときはマウス/キーボードのアイコンのみを、ゲームパッドを使用しているときはゲームパッドアイコンのみを表示するようにします。

今回は「Keyboard&Mouse」「Gamepad」のふたつのSchemeに対応していきます。

これに合わせ、キーボード・マウスからいずれかの入力があった場合にはKeyboard&Mouseに、ゲームパッドからいずれかの入力があった場合にはGamepadに切り替えます。

InputGuide.cs
using System;
using System.Linq;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.DualShock;
using UnityEngine.InputSystem.LowLevel;
using UnityEngine.InputSystem.Switch;
using UnityEngine.InputSystem.XInput;

public class InputGuide : MonoBehaviour
{
    //InputActionReferenceはInputActionAsset内に存在する特定のInputActionへの参照をシリアライズできる
    [SerializeField] private InputActionReference actionReference = default;

    [SerializeField] private TextMeshProUGUI text = default;

    private static readonly StringBuilder TempStringBuilder = new StringBuilder();

    private Action<InputSchemeType> onSchemeChanged;

    public enum InputSchemeType
    {
        KeyboardAndMouse,
        Gamepad
    }

    private InputSchemeType scheme = InputSchemeType.KeyboardAndMouse;

    public InputSchemeType Scheme
    {
        get => scheme;
        private set
        {
            if (scheme == value) return;
            scheme = value;
            OnSchemeChanged?.Invoke(scheme);
        }
    }

    public event Action<InputSchemeType> OnSchemeChanged;

    private void OnEnable()
    {
        UpdateText();
        OnSchemeChanged += onSchemeChanged = _ => UpdateText();
        
        InputSystem.onEvent += OnEvent;
    }

    private void OnDisable()
    {
        if (onSchemeChanged != null)
        {
            OnSchemeChanged -= onSchemeChanged;
            onSchemeChanged = null;
        }
        
        InputSystem.onEvent -= OnEvent;
    }

    private void UpdateText()
    {
        ProcessAction(actionReference.action);
    }

    private void ProcessAction(InputAction action)
    {
        TempStringBuilder.Clear();

        //現在のSchemeにあたる文字列を取得
        var scheme = Scheme switch
        {
            InputSchemeType.Gamepad => "Gamepad",
            InputSchemeType.KeyboardAndMouse => "Keyboard&Mouse",
            _ => null
        };

        InputBinding? bindingMask = string.IsNullOrEmpty(scheme) ? null : InputBinding.MaskByGroup(scheme);

        foreach (var binding in action.bindings)
        {
            //Schemeでフィルタ
            if (bindingMask != null && !bindingMask.Value.Matches(binding)) continue;
            
            //InputBindingのパスを取得する
            //effectivePathは、ランタイムのRebindingなどでoverridePathが設定されている場合も実効的なパスを取得できる
            var path = binding.effectivePath;

            //パスに合致するcontrolを取得する
            var matchedControls = action.controls.Where(control => InputControlPath.Matches(path, control));

            foreach (var control in matchedControls)
            {
                if (control is InputDevice) continue;

                // controlのpathは "/[デバイス名]/[パス]" のようフォーマットになっている
                // このデバイス名は"XInputControllerWindows" や "DualShock4GamepadHID"のように具体的すぎるので、
                // "XInputController"や"DualShockGamepad"のように、アイコンを表示するうえで適度に抽象化されたデバイス名に置き換える

                var deviceIconGroup = GetDeviceIconGroup(control.device);
                if (string.IsNullOrEmpty(deviceIconGroup)) continue;

                var controlPathContent = control.path.Substring(control.device.name.Length + 2);

                string iconName = $"{deviceIconGroup}-{controlPathContent}";

                Debug.Log(iconName);

                var spriteIndex = text.spriteAsset.GetSpriteIndexFromName(iconName);

                if (spriteIndex >= 0)
                {
                    TempStringBuilder.Append("<sprite=");
                    TempStringBuilder.Append(spriteIndex);
                    TempStringBuilder.Append(">");
                }
            }
        }

        TempStringBuilder.Append(" ");
        TempStringBuilder.Append(action.name);

        text.text = TempStringBuilder.ToString();
    }


    private static string GetDeviceIconGroup(InputDevice device)
    {
        return device switch
        {
            Keyboard => "Keyboard",
            Mouse => "Mouse",
            XInputController => "XInputController",
            DualShockGamepad => "DualShockGamepad",
            SwitchProControllerHID => "SwitchProController",
            _ => null
        };
    }

    private void OnEvent(InputEventPtr eventPtr, InputDevice device)
    {
        var eventType = eventPtr.type;
        if (eventType != StateEvent.Type && eventType != DeltaStateEvent.Type)
            return;

        var anyControl = eventPtr.EnumerateControls(
            InputControlExtensions.Enumerate.IncludeNonLeafControls |
            InputControlExtensions.Enumerate.IncludeSyntheticControls |
            InputControlExtensions.Enumerate.IgnoreControlsInCurrentState |
            InputControlExtensions.Enumerate.IgnoreControlsInDefaultState
        ).GetEnumerator().MoveNext();

        if (!anyControl) return;

        Scheme = device switch
        {
            Keyboard or Mouse => InputSchemeType.KeyboardAndMouse,
            Gamepad => InputSchemeType.Gamepad,
            _ => Scheme
        };
    }
}

InputSystem.onEventを使用すると、InputActionやInputBindingをすっ飛ばして、各デバイスから送信される生の入力イベントを拾うことができます。これによって、キーボード、マウス、ゲームパッドに対する「いずれかの入力」でSchemeの変更をトリガーします。

ということで、マウスを動かしたりキーボードをいじるとマウスのアイコンが、ゲームパッドをいじるとゲームパッドのボタンが表示されるようになりました。


現在使っているゲームパッドの種類でフィルタする

現在、接続されているゲームパッドすべてのボタンが表示されていますが、実際にはどれかひとつのゲームパッドを使用することが多いかと思います。最後にこれに対応します。

Gamepad.currentを使用すると、最後に使用したゲームパッドを取得することができます。しかしこの仕組み、デバイスによっては何も入力していないのにGamepad.currentを更新してしまうもの(SwitchProControllerHIDなど)があるようで、何かと難があるため今回は使用しません。

既にInputSystem.onEventで生の入力を受けることができているので、このあたりを拡張して実装します。

InputGuide.cs
using System;
using System.Linq;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.DualShock;
using UnityEngine.InputSystem.LowLevel;
using UnityEngine.InputSystem.Switch;
using UnityEngine.InputSystem.XInput;

public class InputGuide : MonoBehaviour
{
    //InputActionReferenceはInputActionAsset内に存在する特定のInputActionへの参照をシリアライズできる
    [SerializeField] private InputActionReference actionReference = default;

    [SerializeField] private TextMeshProUGUI text = default;

    private static readonly StringBuilder TempStringBuilder = new StringBuilder();

    private Action<InputSchemeType> onSchemeChanged;

    private Gamepad latestGamepad = default;

    public enum InputSchemeType
    {
        KeyboardAndMouse,
        Gamepad
    }

    private InputSchemeType scheme = InputSchemeType.KeyboardAndMouse;

    public event Action<InputSchemeType> OnSchemeChanged;

    private void OnEnable()
    {
        UpdateText();
        OnSchemeChanged += onSchemeChanged = _ => UpdateText();

        InputSystem.onEvent += OnEvent;
    }

    private void OnDisable()
    {
        if (onSchemeChanged != null)
        {
            OnSchemeChanged -= onSchemeChanged;
            onSchemeChanged = null;
        }

        InputSystem.onEvent -= OnEvent;
    }

    private void UpdateText()
    {
        ProcessAction(actionReference.action);
    }

    private void ProcessAction(InputAction action)
    {
        TempStringBuilder.Clear();

        //現在のSchemeにあたる文字列を取得
        var scheme = this.scheme switch
        {
            InputSchemeType.Gamepad => "Gamepad",
            InputSchemeType.KeyboardAndMouse => "Keyboard&Mouse",
            _ => null
        };

        InputBinding? bindingMask = string.IsNullOrEmpty(scheme) ? null : InputBinding.MaskByGroup(scheme);

        foreach (var binding in action.bindings)
        {
            //Schemeでフィルタ
            if (bindingMask != null && !bindingMask.Value.Matches(binding)) continue;

            //InputBindingのパスを取得する
            //effectivePathは、ランタイムのRebindingなどでoverridePathが設定されている場合も実効的なパスを取得できる
            var path = binding.effectivePath;

            //パスに合致するcontrolを取得する
            var matchedControls = action.controls.Where(control => InputControlPath.Matches(path, control));

            foreach (var control in matchedControls)
            {
                if (control is InputDevice) continue;

                //使用していないゲームパッドをスキップする
                if (control.device is Gamepad && latestGamepad != null)
                {
                    if (control.device != latestGamepad) continue;
                }

                // controlのpathは "/[デバイス名]/[パス]" のようフォーマットになっている
                // このデバイス名は"XInputControllerWindows" や "DualShock4GamepadHID"のように具体的すぎるので、
                // "XInputController"や"DualShockGamepad"のように、アイコンを表示するうえで適度に抽象化されたデバイス名に置き換える

                var deviceIconGroup = GetDeviceIconGroup(control.device);
                if (string.IsNullOrEmpty(deviceIconGroup)) continue;

                var controlPathContent = control.path.Substring(control.device.name.Length + 2);

                string iconName = $"{deviceIconGroup}-{controlPathContent}";

                var spriteIndex = text.spriteAsset.GetSpriteIndexFromName(iconName);

                if (spriteIndex >= 0)
                {
                    TempStringBuilder.Append("<sprite=");
                    TempStringBuilder.Append(spriteIndex);
                    TempStringBuilder.Append(">");
                }
            }
        }

        TempStringBuilder.Append(" ");
        TempStringBuilder.Append(action.name);

        text.text = TempStringBuilder.ToString();
    }


    private static string GetDeviceIconGroup(InputDevice device)
    {
        return device switch
        {
            Keyboard => "Keyboard",
            Mouse => "Mouse",
            XInputController => "XInputController",
            DualShockGamepad => "DualShockGamepad",
            SwitchProControllerHID => "SwitchProController",
            _ => null
        };
    }

    private void OnEvent(InputEventPtr eventPtr, InputDevice device)
    {
        var eventType = eventPtr.type;
        if (eventType != StateEvent.Type && eventType != DeltaStateEvent.Type)
            return;

        var controls = eventPtr.EnumerateControls(
            InputControlExtensions.Enumerate.IncludeNonLeafControls |
            InputControlExtensions.Enumerate.IncludeSyntheticControls |
            InputControlExtensions.Enumerate.IgnoreControlsInCurrentState |
            InputControlExtensions.Enumerate.IgnoreControlsInDefaultState
        );

        var anyControl = controls.GetEnumerator().MoveNext();

        if (!anyControl) return;

        bool update = false;

        if (device is Gamepad deviceGamepad && latestGamepad != device)
        {
            latestGamepad = deviceGamepad;
            update = true;
        }

        var newScheme = device switch
        {
            Keyboard or Mouse => InputSchemeType.KeyboardAndMouse,
            Gamepad => InputSchemeType.Gamepad,
            _ => scheme
        };

        if (newScheme != scheme)
        {
            scheme = newScheme;
            update = true;
        }
        
        if(update) OnSchemeChanged?.Invoke(scheme);
    }
}

これで使用しているゲームパッドに限定してアイコンが表示されるようになりました。

ということで、以上になります。

Discussion