2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】OR条件/AND条件で登録できるキーコンフィグシステム作った

2
Posted at

はじめに

はじめまして。普段は主にUnityでゲーム開発を行っている、はとめGamesと申します。
こういった技術記事を書くのは初めてですので、何卒お手柔らかにお願いします。

閑話休題。
2020年から正式版となったUnity公式の新しい入力システム"InputSystem"。
従来のInputManagerから乗り換えている人も多いかと思われます。

InputSystemでは公式でキーコンフィグのサポートをしているのですが、既存のBindingを置き換える(リバインド)という形でしか実装が出来ず、OR条件/AND条件での登録はサポートされていません。
本記事では、1つのActionに対しOR条件/AND条件で登録できるキーコンフィグシステムを作ったので、それを紹介します。

前提条件

Unity 2023.2.22f1
InputSystem 1.7.0

実装方針

前述の通り、リバインドではOR条件/AND条件の指定が出来ません。そこで、既存のBindingを削除→新たなBindingを追加、という形式を採ります。
OR条件は一つのActionに複数のBindingを紐づけるという方法で、AND条件はComposite Bindingとして実装します。このためInputSystemの内部機能を一切弄ることなく実現可能です。
キーコンフィグの保存方法については、キーコンフィグで変更する可能性のあるActionのBindingをすべて独自にシリアライズして、json形式で読み書きすることにします。

完成品

image.gif

コード(一部抜粋)

プロジェクトの全容は上記のリポジトリを参照してください。

  • SerializedBinding.cs
    キーバインド情報を保持する構造体です。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using System.Linq;

[Serializable]
public struct SerializedBinding
{
    public string Path;
    public string Groups;
    public string CompositeName;

    public SerializedBinding(string path, string groups, string compositeName = "null")
    {
        Path = path ?? "null";
        Groups = groups ?? "null";
        CompositeName = compositeName ?? "null";
    }
}

[Serializable]
public struct AndSerializedBinding
{
    public List<SerializedBinding> BindingPairs;

    public AndSerializedBinding(List<SerializedBinding> bindingPairs)
    {
        BindingPairs = bindingPairs;
    }

    public AndSerializedBinding(SerializedBinding bindingPair)
    {
        BindingPairs = new List<SerializedBinding> { bindingPair };
    }
}

[Serializable]
public struct OrSerializedBinding
{
    public int BindingSlotIndex;
    public string Name;
    public List<AndSerializedBinding> AndBindings;

    public OrSerializedBinding(int bindingSlotIndex, string name, List<AndSerializedBinding> andBindings)
    {
        BindingSlotIndex = bindingSlotIndex;
        Name = name ?? "null";
        AndBindings = andBindings;
    }

    public OrSerializedBinding(int bindingSlotIndex, string name, AndSerializedBinding andBinding)
    {
        BindingSlotIndex = bindingSlotIndex;
        Name = name ?? "null";
        AndBindings = new List<AndSerializedBinding> { andBinding };
    }
}

[Serializable]
public struct ActionSerializedBinding
{
    public string ActionName;
    public string ActionMapName;
    public List<OrSerializedBinding> OrBindings;

    public ActionSerializedBinding(string actionName, string actionMapName, List<OrSerializedBinding> orBindings)
    {
        ActionName = actionName;
        ActionMapName = actionMapName;
        OrBindings = orBindings;
    }
}

[Serializable]
public struct KeyConfig
{
    public List<ActionSerializedBinding> ActionSerializedBindings;

    public KeyConfig(List<ActionSerializedBinding> actionSerializedBindings)
    {
        ActionSerializedBindings = actionSerializedBindings;
    }
}

public static class SerializedBindingExtensions
{
    public static ActionSerializedBinding ReadBindings(this InputAction action)
    {
        List<OrSerializedBinding> orBindings = new();

        for (var i = 0; i < action.bindings.Count; i++)
        {
            var binding = action.bindings[i];

            if (!binding.isComposite)
            {
                orBindings.Add(new(i, binding.name, new AndSerializedBinding(new SerializedBinding(binding.path, binding.groups))));
            }
            else
            {
                AndSerializedBinding andBinding = new(new List<SerializedBinding>());

                for (int j = i + 1; j < action.bindings.Count; j++)
                {
                    var child = action.bindings[j];
                    if (!child.isPartOfComposite) break;

                    andBinding.BindingPairs.Add(new SerializedBinding(child.path, child.groups, child.name));
                }

                orBindings.Add(new(i, binding.GetNameOfComposite(), andBinding));
                i += andBinding.BindingPairs.Count;
            }
        }

        return new ActionSerializedBinding(action.actionMap.name, action.name, orBindings);
    }

    public static void WriteBindings(this InputAction action, ActionSerializedBinding bindings)
    {
        // 既存のBindingを全て削除
        while (action.bindings.Count > 0)
        {
            action.ChangeBinding(0).Erase();
        }

        foreach (var orBinding in bindings.OrBindings.OrderBy(b => b.BindingSlotIndex))
        {
            foreach (var andBinding in orBinding.AndBindings)
            {
                if (andBinding.BindingPairs.Count == 1)
                {
                    action.AddBinding(new InputBinding
                    {
                        name = orBinding.Name,
                        path = andBinding.BindingPairs[0].Path != "null" ? andBinding.BindingPairs[0].Path : null,
                        groups = andBinding.BindingPairs[0].Groups != "null" ? andBinding.BindingPairs[0].Groups : null,
                    });
                }
                else
                {
                    var composite = action.AddCompositeBinding(orBinding.Name);
                    for (int i = 0; i < andBinding.BindingPairs.Count; i++)
                    {
                        var path = andBinding.BindingPairs[i].Path;
                        var groups = andBinding.BindingPairs[i].Groups;
                        composite.With(andBinding.BindingPairs[i].CompositeName, path != "null" ? path : null, groups != "null" ? groups : null);
                    }
                }
            }
        }
    }
}
  • AndRebindOperation.cs
    AND条件のキーバインドを生成するクラスです。
    最大3キーまでの同時押しを登録できます。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
using UnityEngine.InputSystem.LowLevel;

public class GroupsBuilder
{
    bool containsKeyboard = false;
    bool containsMouse = false;
    bool containsGamepad = false;

    public void AddDevice(InputDevice device)
    {
        if (device is Keyboard) containsKeyboard = true;
        if (device is Mouse) containsMouse = true;
        if (device is Gamepad) containsGamepad = true;
    }

    public string BuildGroups()
    {
        List<string> list = new();
        if (containsKeyboard) list.Add("Keyboard&Mouse");
        if (containsMouse)
        {
            list.Add("Keyboard&Mouse");
            list.Add("Gamepad&Mouse");
        }
        if (containsGamepad)
        {
            list.Add("Gamepad&Mouse");
            list.Add("Gamepad");
        }
        return string.Join(';', list.Distinct());
    }
}

public enum EscapeAction
{
    cancel, bindNone
}

public class AndRebindOperation : IDisposable
{
    public bool Listening { get; private set; }

    readonly List<string> pressedLayoutPaths = new(3);
    readonly GroupsBuilder groupsBuilder = new();
    InputAction action;
    InputBinding binding;
    IReadOnlyList<Key> ignoredKeys;
    Action<IReadOnlyList<string>> onUpdate;
    Action<InputBinding> onComplete;
    Action onCancel;
    bool useEscapeAction = false;
    EscapeAction escapeAction = EscapeAction.bindNone;

    const float THRESHOLD = 0.5f;

    public AndRebindOperation(InputAction action, InputBinding binding, IReadOnlyList<Key> ignoredKeys)
    {
        this.action = action;
        this.binding = binding;
        this.ignoredKeys = ignoredKeys;
        if (binding.isPartOfComposite) throw new Exception("A part of composite bind is not allowed to AND Rebinding!");
    }
    public AndRebindOperation OnUpdate(Action<IReadOnlyList<string>> onUpdate)
    {
        this.onUpdate = onUpdate;
        return this;
    }
    public AndRebindOperation OnComplete(Action<InputBinding> onComplete)
    {
        this.onComplete = onComplete;
        return this;
    }
    public AndRebindOperation OnCancel(Action onCancel)
    {
        this.onCancel = onCancel;
        return this;
    }
    public AndRebindOperation WithEscapeAction(EscapeAction escapeAction)
    {
        this.escapeAction = escapeAction;
        useEscapeAction = true;
        return this;
    }
    public AndRebindOperation Start()
    {
        if (Listening) return this;
        Listening = true;

        InputSystem.onEvent += OnInputEvent;
        return this;
    }

    // 置き換え前のバインディングを削除
    void RemoveCurrentBinding()
    {
        if (binding.isComposite)
        {
            var childSyntax = action.ChangeBinding(binding).NextBinding();
            while (childSyntax.valid && childSyntax.binding.isPartOfComposite)
            {
                childSyntax.Erase();
                childSyntax = childSyntax.NextBinding();
            }
        }
        action.ChangeBinding(binding).Erase();
    }

    void Complete()
    {
        if (!Listening) return;
        Listening = false;

        RemoveCurrentBinding();

        string groups = groupsBuilder.BuildGroups();

        if (pressedLayoutPaths.Count == 1)
        {
            // 単一バインディングを追加
            var newBinding = new InputBinding { path = pressedLayoutPaths[0], groups = groups };
            action.AddBinding(newBinding);

            onComplete?.Invoke(newBinding);
        }
        else if (pressedLayoutPaths.Count == 2)
        {
            // OneModifierバインディングを追加
            var composite = action.AddCompositeBinding("OneModifier");
            composite.With("Modifier", pressedLayoutPaths[0], groups);
            composite.With("Binding", pressedLayoutPaths[1], groups);
            var newBinding = action.bindings[composite.bindingIndex];

            onComplete?.Invoke(newBinding);
        }
        else if (pressedLayoutPaths.Count == 3)
        {
            // TwoModifiersバインディングを追加
            var composite = action.AddCompositeBinding("TwoModifiers");
            composite.With("Modifier1", pressedLayoutPaths[0], groups);
            composite.With("Modifier2", pressedLayoutPaths[1], groups);
            composite.With("Binding", pressedLayoutPaths[2], groups);
            var newBinding = action.bindings[composite.bindingIndex];

            onComplete?.Invoke(newBinding);
        }

        Dispose();
    }

    public void Cancel()
    {
        if (!Listening) return;
        Listening = false;

        onCancel?.Invoke();
        Dispose();
    }

    public void BindNone()
    {
        if (!Listening) return;
        Listening = false;

        RemoveCurrentBinding();

        var noneBinding = new InputBinding { path = "<None>" };
        action.AddBinding(noneBinding);

        onComplete?.Invoke(noneBinding);
        Dispose();
    }

    public void Dispose()
    {
        InputSystem.onEvent -= OnInputEvent;
    }

    void OnInputEvent(InputEventPtr eventPtr, InputDevice device)
    {
        if (!eventPtr.IsA<StateEvent>() && !eventPtr.IsA<DeltaStateEvent>()) return;

        foreach (var control in device.allControls)
        {
            if (control.synthetic) continue;

            if (control is KeyControl key && ignoredKeys.Contains(key.keyCode)) continue;

            switch (control)
            {
                case ButtonControl button:
                    if (!button.ReadValueFromEvent(eventPtr, out float valueButton)) continue;
                    string layoutPathButton;
                    if (control.path.Contains("/dpad")) layoutPathButton = $"<{GameInput.GetTopLayout(control.device.layout)}>/dpad/{control.name}";
                    else layoutPathButton = $"<{GameInput.GetTopLayout(control.device.layout)}>/{control.name}";
                    if (valueButton > THRESHOLD)
                    {
                        if (useEscapeAction && control == Keyboard.current.escapeKey)
                        {
                            if (escapeAction == EscapeAction.cancel) Cancel();
                            else BindNone();
                            return;
                        }

                        if (pressedLayoutPaths.Count >= 3) continue;
                        if (pressedLayoutPaths.Contains(layoutPathButton)) continue;
                        pressedLayoutPaths.Add(layoutPathButton);
                        groupsBuilder.AddDevice(control.device);
                        onUpdate?.Invoke(pressedLayoutPaths);
                    }
                    else if (pressedLayoutPaths.Contains(layoutPathButton))
                    {
                        Complete();
                        return;
                    }
                    break;
                case Vector2Control vector:
                    if (control.device is not Gamepad) continue;
                    if (control.path.Contains("/dpad")) continue;
                    if (!vector.ReadValueFromEvent(eventPtr, out Vector2 valueVector)) continue;
                    string layoutPathVector = $"<{GameInput.GetTopLayout(control.device.layout)}>/{control.path.Substring(control.device.name.Length + 2)}";
                    string layoutPathVectorWithDirection;
                    if (Mathf.Abs(valueVector.x) > Mathf.Abs(valueVector.y))
                    {
                        layoutPathVectorWithDirection = layoutPathVector + (valueVector.x > 0 ? "/right" : "/left");
                    }
                    else
                    {
                        layoutPathVectorWithDirection = layoutPathVector + (valueVector.y > 0 ? "/up" : "/down");
                    }
                    if (valueVector.sqrMagnitude > THRESHOLD * THRESHOLD)
                    {
                        if (pressedLayoutPaths.Count >= 3) continue;
                        if (pressedLayoutPaths.Contains(layoutPathVectorWithDirection)) continue;
                        pressedLayoutPaths.Add(layoutPathVectorWithDirection);
                        groupsBuilder.AddDevice(control.device);
                        onUpdate?.Invoke(pressedLayoutPaths);
                    }
                    else if (pressedLayoutPaths.Contains(layoutPathVector + "/right")
                          || pressedLayoutPaths.Contains(layoutPathVector + "/left")
                          || pressedLayoutPaths.Contains(layoutPathVector + "/up")
                          || pressedLayoutPaths.Contains(layoutPathVector + "/down"))
                    {
                        Complete();
                        return;
                    }
                    break;
                default:
                    continue;
            }
        }
    }
}
  • KeyConfigManager.cs
    後述のActionSlotとBindingSlotを管理します。
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEngine.InputSystem;
using System;

public class KeyConfigManager : MonoBehaviour
{
    public event Action OnKeyConfigChanged;

    [SerializeField] GameInput gameInput;
    [SerializeField] KeyboardLocaleSupporter keyboardLocaleSupporter;
    [SerializeField] Transform ScrollContent;
    [SerializeField] UIBlocker uiBlocker;

    Dictionary<InputAction, ActionSlot> actionSlots = new();
    AndRebindOperation rebindOperation;
    string keyConfigFilePath => Path.Combine(Application.dataPath, "KeyConfig.json");

    public void Save()
    {
        RemoveDuplicates();
        List<ActionSerializedBinding> bindings = new();
        foreach (var actionSlot in actionSlots.Values)
        {
            bindings.Add(actionSlot.ReadBindings());
        }
        KeyConfig keyConfig = new(bindings);

        string json = JsonUtility.ToJson(keyConfig, true);
        File.WriteAllText(keyConfigFilePath, json);
        print("Saved!");
    }

    public void Load()
    {
        if (!File.Exists(keyConfigFilePath))
        {
            Save();
            return;
        }

        string json = File.ReadAllText(keyConfigFilePath);

        try
        {
            var keyConfig = JsonUtility.FromJson<KeyConfig>(json);

            foreach (var actionSerializedBinding in keyConfig.ActionSerializedBindings)
            {
                InputAction action = gameInput.FindActionMap(actionSerializedBinding.ActionMapName).FindAction(actionSerializedBinding.ActionName);
                actionSlots[action].WriteBindings(actionSerializedBinding);
            }
            OnKeyConfigChanged?.Invoke();
            print("Loaded!");
        }
        catch (System.Exception)
        {
            print("Invalid key config data detected. Reverting to default config.");
            Revert();
        }
    }

    public void Revert()
    {
        foreach (var actionSlot in actionSlots.Values)
        {
            actionSlot.Revert();
        }
        OnKeyConfigChanged?.Invoke();
        print("Reverted!");
    }

    public void RemoveDuplicates()
    {
        foreach (var actionSlot in actionSlots.Values)
        {
            actionSlot.RemoveDuplicates();
        }
    }

    void Start()
    {
        foreach (var actionSlot in ScrollContent.GetComponentsInChildren<ActionSlot>())
        {
            InputAction action = gameInput.FindAction(actionSlot.InputActionReference.action.id.ToString());

            if (action == null) continue;

            actionSlot.Init(action, StartRebinding, keyboardLocaleSupporter);
            actionSlots.Add(action, actionSlot);
        }
    }

    void StartRebinding(BindingSlot bindingSlot)
    {
        InputAction action = bindingSlot.Action;

        if (action == null) return;

        bindingSlot.ShowWaitingDisplayText();
        rebindOperation?.Cancel();

        uiBlocker.SetTopText("Press any key to regist").SetBottomText("<sprite name=Keyboard.Escape> to cancel").Show(true);
        gameInput.EnableUIInputModule(false);
        action.actionMap.Disable();
        action.Disable();

        rebindOperation = new AndRebindOperation(action, bindingSlot.Binding, keyboardLocaleSupporter.IgnoredKeys)
            .WithEscapeAction(EscapeAction.cancel)
            .OnUpdate(paths =>
            {
                string iconPath = string.Join('&', paths.Select(p => $"<sprite name=\"{p.Replace("<", "").Replace(">/", ".")}\">"));
                iconPath = keyboardLocaleSupporter.ConvertIconPathForCurrentLocale(iconPath);
                uiBlocker.SetMiddleText(iconPath);
            })
            .OnComplete(newBinding =>
            {
                bindingSlot.SetBinding(action, newBinding);
                OnEndOperation();
            })
            .OnCancel(OnEndOperation)
            .Start();

        void OnEndOperation()
        {
            action.Enable();
            action.actionMap.Enable();
            gameInput.EnableUIInputModule(true);
            uiBlocker.Show(false);

            bindingSlot.RefreshDisplayText();

            CleanUpRebindOperation();

            OnKeyConfigChanged?.Invoke();
        }
    }

    void CleanUpRebindOperation()
    {
        rebindOperation?.Dispose();
        rebindOperation = null;
    }

    void OnDestroy()
    {
        CleanUpRebindOperation();
    }
}
  • ActionSlot.cs
    キーコンフィグ対象のActionをInputActionReferenceで指定して使います。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

public static class EnumerableExtension
{
    public static IEnumerable<T> FindDuplication<T>(this IEnumerable<T> enumrable)
    {
        return enumrable.GroupBy(name => name).Where(name => name.Count() > 1).Select(group => group.Key);
    }
}

public class ActionSlot : MonoBehaviour
{
    [field: SerializeField] public InputActionReference InputActionReference { get; private set; }

    [SerializeField] Transform bindingSlotsHolder;
    [SerializeField] GameObject bindingSlotPrefab;

    [SerializeField] Button buttonRevert;
    [SerializeField] Button buttonAdd;

    event Action onButtonRevertClicked;
    event Action onButtonAddClicked;
    Action<BindingSlot> startRebinding;
    InputAction action;
    public List<BindingSlot> bindingSlots = new();
    ActionSerializedBinding defaultKeyConfig;
    bool initialized = false;
    KeyboardLocaleSupporter keyboardLocaleSupporter;

    void Awake()
    {
        buttonRevert.onClick.AddListener(() => onButtonRevertClicked());
        buttonAdd.onClick.AddListener(() => onButtonAddClicked());
    }

    public void Init(InputAction action, Action<BindingSlot> startRebinding, KeyboardLocaleSupporter keyboardLocaleSupporter)
    {
        if (initialized) return;
        initialized = true;

        this.action = action;
        this.startRebinding = startRebinding;
        this.keyboardLocaleSupporter = keyboardLocaleSupporter;

        defaultKeyConfig = action.ReadBindings();

        onButtonRevertClicked += () =>
        {
            Revert();
        };

        onButtonAddClicked += () =>
        {
            action.actionMap.Disable();
            action.Disable();
            AddBindingSlot(action.AddBinding("<None>").binding);
            action.Enable();
            action.actionMap.Enable();
        };

        RefreshUI();
    }

    public void Revert()
    {
        action.WriteBindings(defaultKeyConfig);
        RefreshUI();
    }

    public ActionSerializedBinding ReadBindings()
    {
        List<OrSerializedBinding> orBindings = new();

        for (int i = 0; i < bindingSlots.Count; i++)
        {
            var bindingSlot = bindingSlots[i];
            var binding = bindingSlot.Binding;
            if (!binding.isComposite)
            {
                orBindings.Add(new(i, binding.name, new AndSerializedBinding(new SerializedBinding(binding.path, binding.groups))));
            }
            else
            {
                AndSerializedBinding andBinding = new(new List<SerializedBinding>());

                for (int j = action.GetBindingIndex(binding) + 1; j < action.bindings.Count; j++)
                {
                    var child = action.bindings[j];
                    if (!child.isPartOfComposite) break;

                    andBinding.BindingPairs.Add(new SerializedBinding(child.path, child.groups, child.name));
                }

                orBindings.Add(new(i, binding.GetNameOfComposite(), andBinding));
            }
        }

        return new ActionSerializedBinding(action.name, action.actionMap.name, orBindings);
    }

    public void WriteBindings(ActionSerializedBinding bindings)
    {
        action.WriteBindings(bindings);
        RefreshUI();
    }

    public void RemoveDuplicates()
    {
        foreach (var path in action.bindings.Select(b => b.path).FindDuplication())
        {
            var slots = bindingSlots.Where(b => b.Binding.path == path).Where((b, i) => i != 0).ToArray();
            foreach (var bindingSlot in slots)
            {
                RemoveBindingSlot(bindingSlot);
            }
        }
    }

    void AddBindingSlot(InputBinding binding)
    {
        void UnsubscribeAndRemoveBindingSlot(BindingSlot bindingSlot)
        {
            bindingSlot.OnButtonRegistClicked -= OnButtonRegistClicked;
            bindingSlot.OnButtonRemoveClicked -= OnButtonRemoveClicked;

            RemoveBindingSlot(bindingSlot);
        }

        var bindingSlot = Instantiate(bindingSlotPrefab, bindingSlotsHolder).GetComponent<BindingSlot>();
        bindingSlot.Init(keyboardLocaleSupporter);
        bindingSlot.SetBinding(action, binding);
        bindingSlots.Add(bindingSlot);

        void OnButtonRegistClicked() => startRebinding(bindingSlot);
        void OnButtonRemoveClicked() => UnsubscribeAndRemoveBindingSlot(bindingSlot);

        bindingSlot.OnButtonRegistClicked += OnButtonRegistClicked;
        bindingSlot.OnButtonRemoveClicked += OnButtonRemoveClicked;
    }

    void RemoveBindingSlot(BindingSlot bindingSlot)
    {
        bindingSlot.UnsubscribeFromLocaleChangeEvent(keyboardLocaleSupporter);

        action.actionMap.Disable();
        action.Disable();
        var syntax = action.ChangeBinding(bindingSlot.Binding);
        if (syntax.valid) syntax.Erase();
        action.Enable();
        action.actionMap.Enable();
        bindingSlots.Remove(bindingSlot);
        Destroy(bindingSlot.gameObject);
    }

    void RefreshUI()
    {
        int i = 0;
        foreach (var binding in action.bindings)
        {
            if (binding.isPartOfComposite) continue;

            if (i < bindingSlots.Count)
            {
                bindingSlots[i].SetBinding(action, binding);
            }
            else
            {
                AddBindingSlot(binding);
            }
            i++;
        }
        while (bindingSlots.Count > i)
        {
            RemoveBindingSlot(bindingSlots[i]);
        }
    }
}
  • BindingSlot.cs
    各キーバインドに対応するUIを提供します。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

public class BindingSlot : MonoBehaviour
{
    public event Action OnButtonRegistClicked;
    public event Action OnButtonRemoveClicked;

    public InputAction Action { get; private set; }
    public InputBinding Binding { get; private set; }

    [SerializeField] TMP_Text bindingText;
    [SerializeField] TMP_Text deviceText;
    [SerializeField] Button buttonRegist;
    [SerializeField] Button buttonRemove;

    KeyboardLocaleSupporter keyboardLocaleSupporter;

    void Awake()
    {
        buttonRegist.onClick.AddListener(() => OnButtonRegistClicked());
        buttonRemove.onClick.AddListener(() => OnButtonRemoveClicked());
    }

    public void Init(KeyboardLocaleSupporter keyboardLocaleSupporter)
    {
        this.keyboardLocaleSupporter = keyboardLocaleSupporter;

        keyboardLocaleSupporter.OnKeyboardLocaleChanged += RefreshDisplayText;
    }

    public void UnsubscribeFromLocaleChangeEvent(KeyboardLocaleSupporter keyboardLocaleSupporter)
    {
        keyboardLocaleSupporter.OnKeyboardLocaleChanged -= RefreshDisplayText;
    }

    public void SetBinding(InputAction action, InputBinding binding)
    {
        Action = action;
        Binding = binding;
        RefreshDisplayText();
    }

    public void ShowWaitingDisplayText()
    {
        bindingText.SetText("Waiting for input ...");
        deviceText.SetText("");
    }

    public void RefreshDisplayText()
    {
        bindingText.SetText(string.Join('&', GetIconPaths()));
        deviceText.SetText(string.Join("", GetDevicePaths()));
    }

    List<string> GetIconPaths()
    {
        if (!Binding.isComposite)
        {
            return new List<string> { GetIconPath(Binding) };
        }
        else
        {
            List<string> list = new();
            for (int i = Action.GetBindingIndex(Binding) + 1; i < Action.bindings.Count; i++)
            {
                var binding = Action.bindings[i];
                if (!binding.isPartOfComposite) break;
                list.Add(GetIconPath(binding));
            }
            return list;
        }
    }

    string GetIconPath(InputBinding binding)
    {
        Action.GetBindingDisplayString(Action.GetBindingIndex(binding), out string layout, out string content);
        layout = GameInput.GetTopLayout(layout);
        string iconPath = layout != "None" ? $"<sprite name=\"{layout}.{content}\">" : "Empty Binding";
        iconPath = keyboardLocaleSupporter.ConvertIconPathForCurrentLocale(iconPath);
        return iconPath;
    }

    List<string> GetDevicePaths()
    {
        if (!Binding.isComposite)
        {
            return new List<string> { GetDevicePath(Binding) };
        }
        else
        {
            List<string> list = new();
            for (int i = Action.GetBindingIndex(Binding) + 1; i < Action.bindings.Count; i++)
            {
                var binding = Action.bindings[i];
                if (!binding.isPartOfComposite) break;
                list.Add(GetDevicePath(binding));
            }
            return list.Distinct().ToList();
        }
    }

    string GetDevicePath(InputBinding binding)
    {
        Action.GetBindingDisplayString(Action.GetBindingIndex(binding), out string layout, out _);
        layout = GameInput.GetTopLayout(layout);
        return layout != "None" ? $"<sprite name=\"{layout}\">" : "";
    }
}

実装にあたって躓いた点

  • bindingIndexは各アクションについて連番
    CompositeBindingも一つのbindingとしてカウントされます。
    今回のようなbindingが入れ替わるケースでは、bindingIndexによるbindingの取得は非推奨のようです。
    action.bindingsをforeachで走査する際は、binding.isCompositeで判定したのち、CompositeBindingである場合にはbinding.isPartOfCompositefalseになるまでの間に取得できたbindingCompositeBindingの子要素だと見なします。
  • InputActionReferenceactionは新規に生成したインスタンスを返す
    これはInputActionAssetからFindActionで取得できるactionとは別物の実体です。
    InputActionAssetに紐づいたactionを入手したい場合は、action.id経由でInputActionAssetから取得する必要があります。
  • D-padはButtonControlとしてもVector2Controlとしても取得できてしまう
    単一の操作で二種類の入力を取得してしまうので、それらがAND条件と見なされないよう条件分岐しています。
  • 入力デバイスのPathにはLayoutPathControlPathの二種類がある
    <Keyboard>/escape ←これはLayoutPath
    /Keyboard/escape ←これはControlPath
    ControlPathは入力デバイスの実体に紐づいており、それを入力デバイスの種別に応じて抽象化したものがLayoutPathのようです。
    InputControlから取得できるのはControlPathであるのに対し、キーコンフィグの保存に使用するのはLayoutPathなので、変換を嚙ます必要がありました。
  • layoutは継承関係を持つ
    例えばXbox ControllerPlayStation ControllerGamepadを継承しています。
    この継承関係はWindow → Analysis → InputDebugger → Layoutsの各項目のExtends:から確認できます。
    どの種類のコントローラでも等価的に扱いたい場合は、layoutbaseLayoutsを辿っていって親元のlayoutを取得する必要があります。
    注意点として、MousePointerを継承しているため、baseLayoutsを辿っていく過程でMouseが出たらそれ以上は辿らないようにする必要があります。
  • Sprite AssetのサイズはSprite Glyph Tableで変更する
    Sprite Character Tableの方のScaleを弄っても大きさが変化しません。
  • Sprite Characterの名前に<>は使用できない
    "<sprite name="名前">"という記法の都合上、<>は使用できません。
    LayoutPathをそのままSprite Characterの名前に出来ないため、Sprite Characterの指定には.で区切ったIconPathという独自記法を使用しました。
  • JISキーボードの配列はUSキーボードの配列と一部異なる
    これはUnityに限った話ではありませんが、アルファベットのキーより右側のキー群には、JISとUSで別の記号が割り当てられています。
    Unityで取得できるのはUS配列であるため、JIS配列に表記を合わせるには変換テーブルを挟むほかありません。
    自動的にキーボードの配列を検出するのは不可能だそうで、やむなくDropdownでの選択式にしました。

おわりに

本記事ではOR条件/AND条件で登録できるキーコンフィグシステムの実装方法を紹介しました。
ゲーム開発のお役に立つことが出来れば幸いです。

参考にしたサイト

https://docs.unity3d.com/Packages/com.unity.inputsystem@1.18/api/index.html
https://nekojara.city/unity-input-system-rebinding
https://nekojara.city/unity-input-system-custom-rebinding

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?