はじめに
はじめまして。普段は主に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形式で読み書きすることにします。
完成品
コード(一部抜粋)
プロジェクトの全容は上記のリポジトリを参照してください。
- 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.isPartOfCompositeがfalseになるまでの間に取得できたbindingがCompositeBindingの子要素だと見なします。 -
InputActionReferenceのactionは新規に生成したインスタンスを返す
これはInputActionAssetからFindActionで取得できるactionとは別物の実体です。
InputActionAssetに紐づいたactionを入手したい場合は、action.id経由でInputActionAssetから取得する必要があります。 - D-padは
ButtonControlとしてもVector2Controlとしても取得できてしまう
単一の操作で二種類の入力を取得してしまうので、それらがAND条件と見なされないよう条件分岐しています。 - 入力デバイスのPathには
LayoutPathとControlPathの二種類がある
<Keyboard>/escape←これはLayoutPath。
/Keyboard/escape←これはControlPath。
ControlPathは入力デバイスの実体に紐づいており、それを入力デバイスの種別に応じて抽象化したものがLayoutPathのようです。
InputControlから取得できるのはControlPathであるのに対し、キーコンフィグの保存に使用するのはLayoutPathなので、変換を嚙ます必要がありました。 -
layoutは継承関係を持つ
例えばXbox ControllerやPlayStation ControllerはGamepadを継承しています。
この継承関係はWindow → Analysis → InputDebugger → Layoutsの各項目のExtends:から確認できます。
どの種類のコントローラでも等価的に扱いたい場合は、layoutのbaseLayoutsを辿っていって親元のlayoutを取得する必要があります。
注意点として、MouseはPointerを継承しているため、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
