535 lines
20 KiB
C#
535 lines
20 KiB
C#
// InputBindingManager.cs
|
|
|
|
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Collections.Generic;
|
|
using System.Reactive.Linq;
|
|
using System.Threading.Tasks;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using System.Reactive.Subjects;
|
|
using AlicizaX.InputGlyph;
|
|
using RxUnit = System.Reactive.Unit;
|
|
|
|
namespace InputRemapper
|
|
{
|
|
public class InputBindingManager : MonoBehaviour
|
|
{
|
|
public const string NULL_BINDING = "__NULL__";
|
|
|
|
[Tooltip("InputActionAsset to manage")]
|
|
public InputActionAsset actions;
|
|
|
|
[SerializeField] private InputGlyphDatabase inputGlyphDatabase;
|
|
|
|
public string fileName = "input_bindings.json";
|
|
public bool debugMode = false;
|
|
|
|
public Dictionary<string, ActionMap> actionMap = new Dictionary<string, ActionMap>();
|
|
public List<RebindContext> preparedRebinds = new List<RebindContext>();
|
|
|
|
internal InputActionRebindingExtensions.RebindingOperation rebindOperation;
|
|
private readonly List<string> pressedActions = new List<string>();
|
|
private bool isApplyPending = false;
|
|
private string defaultBindingsJson = string.Empty;
|
|
|
|
public ReplaySubject<RxUnit> OnInputsInit = new ReplaySubject<RxUnit>(1);
|
|
public Subject<bool> OnApply = new Subject<bool>();
|
|
public Subject<RebindContext> OnRebindPrepare = new Subject<RebindContext>();
|
|
public Subject<RxUnit> OnRebindStart = new Subject<RxUnit>();
|
|
public Subject<bool> OnRebindEnd = new Subject<bool>();
|
|
public Subject<(RebindContext prepared, RebindContext conflict)> OnRebindConflict = new Subject<(RebindContext, RebindContext)>();
|
|
|
|
public string SavePath
|
|
{
|
|
get
|
|
{
|
|
#if UNITY_EDITOR
|
|
string folder = Application.dataPath;
|
|
#else
|
|
string folder = Application.persistentDataPath;
|
|
#endif
|
|
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
|
return Path.Combine(folder, fileName);
|
|
}
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
GlyphService.Database = inputGlyphDatabase;
|
|
if (actions == null)
|
|
{
|
|
Debug.LogError("InputBindingManager: InputActionAsset not assigned.");
|
|
return;
|
|
}
|
|
|
|
BuildActionMap();
|
|
|
|
try
|
|
{
|
|
defaultBindingsJson = actions.SaveBindingOverridesAsJson();
|
|
}
|
|
catch
|
|
{
|
|
defaultBindingsJson = string.Empty;
|
|
}
|
|
|
|
if (File.Exists(SavePath))
|
|
{
|
|
try
|
|
{
|
|
var json = File.ReadAllText(SavePath);
|
|
if (!string.IsNullOrEmpty(json))
|
|
{
|
|
actions.LoadBindingOverridesFromJson(json);
|
|
RefreshBindingPathsFromActions();
|
|
if (debugMode) Debug.Log($"Loaded overrides from {SavePath}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError("Failed to load overrides: " + ex);
|
|
}
|
|
}
|
|
|
|
OnInputsInit.OnNext(RxUnit.Default);
|
|
actions.Enable();
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
rebindOperation?.Dispose();
|
|
rebindOperation = null;
|
|
|
|
OnInputsInit?.OnCompleted();
|
|
OnApply?.OnCompleted();
|
|
OnRebindPrepare?.OnCompleted();
|
|
OnRebindStart?.OnCompleted();
|
|
OnRebindEnd?.OnCompleted();
|
|
OnRebindConflict?.OnCompleted();
|
|
}
|
|
|
|
private void BuildActionMap()
|
|
{
|
|
actionMap.Clear();
|
|
foreach (var map in actions.actionMaps)
|
|
actionMap.Add(map.name, new ActionMap(map));
|
|
}
|
|
|
|
private void RefreshBindingPathsFromActions()
|
|
{
|
|
foreach (var mapPair in actionMap)
|
|
{
|
|
foreach (var actionPair in mapPair.Value.actions)
|
|
{
|
|
var a = actionPair.Value;
|
|
foreach (var bpair in a.bindings)
|
|
{
|
|
bpair.Value.bindingPath.EffectivePath = a.action.bindings[bpair.Key].effectivePath;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public class ActionMap
|
|
{
|
|
public string name;
|
|
public Dictionary<string, Action> actions = new Dictionary<string, Action>();
|
|
|
|
public ActionMap(InputActionMap map)
|
|
{
|
|
name = map.name;
|
|
foreach (var action in map.actions) actions.Add(action.name, new Action(action));
|
|
}
|
|
|
|
public class Action
|
|
{
|
|
public InputAction action;
|
|
public Dictionary<int, Binding> bindings = new Dictionary<int, Binding>();
|
|
|
|
public Action(InputAction action)
|
|
{
|
|
this.action = action;
|
|
int count = action.bindings.Count;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (action.bindings[i].isComposite)
|
|
{
|
|
int first = i + 1;
|
|
int last = first;
|
|
while (last < count && action.bindings[last].isPartOfComposite) last++;
|
|
for (int p = first; p < last; p++)
|
|
AddBinding(action.bindings[p], p);
|
|
i = last - 1;
|
|
}
|
|
else
|
|
{
|
|
AddBinding(action.bindings[i], i);
|
|
}
|
|
}
|
|
|
|
void AddBinding(InputBinding binding, int bindingIndex)
|
|
{
|
|
bindings.Add(bindingIndex, new Binding
|
|
{
|
|
name = binding.name,
|
|
parentAction = action.name,
|
|
compositePart = binding.name,
|
|
bindingIndex = bindingIndex,
|
|
group = binding.groups?.Split(InputBinding.Separator) ?? Array.Empty<string>(),
|
|
bindingPath = new BindingPath(binding.path, binding.overridePath),
|
|
inputBinding = binding
|
|
});
|
|
}
|
|
}
|
|
|
|
public struct Binding
|
|
{
|
|
public string name;
|
|
public string parentAction;
|
|
public string compositePart;
|
|
public int bindingIndex;
|
|
public string[] group;
|
|
public BindingPath bindingPath;
|
|
public InputBinding inputBinding;
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class BindingPath
|
|
{
|
|
public string bindingPath;
|
|
public string overridePath;
|
|
private readonly Subject<RxUnit> observer = new Subject<RxUnit>();
|
|
|
|
public BindingPath(string bindingPath, string overridePath)
|
|
{
|
|
this.bindingPath = bindingPath;
|
|
this.overridePath = overridePath;
|
|
}
|
|
|
|
public string EffectivePath
|
|
{
|
|
get => !string.IsNullOrEmpty(overridePath) ? overridePath : bindingPath;
|
|
set
|
|
{
|
|
overridePath = (value == bindingPath) ? string.Empty : value;
|
|
observer.OnNext(RxUnit.Default);
|
|
}
|
|
}
|
|
|
|
public IObservable<string> EffectivePathObservable => observer.Select(_ => EffectivePath);
|
|
}
|
|
|
|
public class RebindContext
|
|
{
|
|
public InputAction action;
|
|
public int bindingIndex;
|
|
public string overridePath;
|
|
|
|
public RebindContext(InputAction action, int bindingIndex, string overridePath)
|
|
{
|
|
this.action = action;
|
|
this.bindingIndex = bindingIndex;
|
|
this.overridePath = overridePath;
|
|
}
|
|
|
|
public override bool Equals(object obj)
|
|
{
|
|
if (obj is not RebindContext other) return false;
|
|
if (action == null || other.action == null) return false;
|
|
return action.name == other.action.name && bindingIndex == other.bindingIndex;
|
|
}
|
|
|
|
public override int GetHashCode() => (action?.name ?? string.Empty, bindingIndex).GetHashCode();
|
|
public override string ToString() => $"{action?.name ?? "<null>"}:{bindingIndex}";
|
|
}
|
|
|
|
/* ---------------- Public API ---------------- */
|
|
|
|
public static InputAction Action(string actionName)
|
|
{
|
|
foreach (var map in Instance.actionMap)
|
|
{
|
|
if (map.Value.actions.TryGetValue(actionName, out var a)) return a.action;
|
|
}
|
|
|
|
Debug.LogError($"[InputBindingManager] Could not find action '{actionName}'");
|
|
return null;
|
|
}
|
|
|
|
public static void StartRebind(string actionName, string compositePartName = null)
|
|
{
|
|
var action = Action(actionName);
|
|
if (action == null) return;
|
|
|
|
// decide bindingIndex & deviceMatch automatically
|
|
int bindingIndex = Instance.FindBestBindingIndexForKeyboard(action, compositePartName);
|
|
if (bindingIndex < 0)
|
|
{
|
|
Debug.LogError($"[InputBindingManager] No suitable binding found for action '{actionName}' (part={compositePartName ?? "<null>"})");
|
|
return;
|
|
}
|
|
|
|
Instance.actions.Disable();
|
|
Instance.PerformInteractiveRebinding(action, bindingIndex, "<Keyboard>", true);
|
|
Instance.OnRebindStart.OnNext(RxUnit.Default);
|
|
if (Instance.debugMode) Debug.Log("[InputBindingManager] Rebind started");
|
|
}
|
|
|
|
public static void CancelRebind() => Instance.rebindOperation?.Cancel();
|
|
|
|
public static async Task<bool> ConfirmApply(bool clearConflicts = true)
|
|
{
|
|
if (!Instance.isApplyPending) return false;
|
|
|
|
try
|
|
{
|
|
foreach (var ctx in Instance.preparedRebinds)
|
|
{
|
|
if (string.IsNullOrEmpty(ctx.overridePath))
|
|
{
|
|
}
|
|
else if (ctx.overridePath == NULL_BINDING) ctx.action.RemoveBindingOverride(ctx.bindingIndex);
|
|
else ctx.action.ApplyBindingOverride(ctx.bindingIndex, ctx.overridePath);
|
|
|
|
var bp = GetBindingPath(ctx.action.name, ctx.bindingIndex);
|
|
if (bp != null) bp.EffectivePath = (ctx.overridePath == NULL_BINDING) ? string.Empty : ctx.overridePath;
|
|
}
|
|
|
|
Instance.preparedRebinds.Clear();
|
|
await Instance.WriteOverridesToDiskAsync();
|
|
Instance.OnApply.OnNext(true);
|
|
Instance.isApplyPending = false;
|
|
if (Instance.debugMode) Debug.Log("[InputBindingManager] Apply confirmed and saved.");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError("[InputBindingManager] Failed to apply binds: " + ex);
|
|
Instance.OnApply.OnNext(false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static void DiscardPrepared()
|
|
{
|
|
if (!Instance.isApplyPending) return;
|
|
Instance.preparedRebinds.Clear();
|
|
Instance.isApplyPending = false;
|
|
Instance.OnApply.OnNext(false);
|
|
if (Instance.debugMode) Debug.Log("[InputBindingManager] Prepared rebinds discarded.");
|
|
}
|
|
|
|
private void PerformInteractiveRebinding(InputAction action, int bindingIndex, string deviceMatchPath = null, bool excludeMouseMovementAndScroll = true)
|
|
{
|
|
var op = action.PerformInteractiveRebinding(bindingIndex);
|
|
|
|
if (!string.IsNullOrEmpty(deviceMatchPath)) op = op.WithControlsHavingToMatchPath(deviceMatchPath);
|
|
if (excludeMouseMovementAndScroll)
|
|
{
|
|
op = op.WithControlsExcluding("<Mouse>/delta");
|
|
op = op.WithControlsExcluding("<Mouse>/scroll");
|
|
op = op.WithControlsExcluding("<Mouse>/scroll/x");
|
|
op = op.WithControlsExcluding("<Mouse>/scroll/y");
|
|
}
|
|
|
|
rebindOperation = op
|
|
.OnApplyBinding((o, path) =>
|
|
{
|
|
if (AnyPreparedRebind(path, action, bindingIndex, out var existing))
|
|
{
|
|
PrepareRebind(new RebindContext(action, bindingIndex, path));
|
|
PrepareRebind(new RebindContext(existing.action, existing.bindingIndex, NULL_BINDING));
|
|
OnRebindConflict.OnNext((new RebindContext(action, bindingIndex, path), existing));
|
|
}
|
|
else if (AnyBindingPath(path, action, bindingIndex, out var dup))
|
|
{
|
|
PrepareRebind(new RebindContext(action, bindingIndex, path));
|
|
PrepareRebind(new RebindContext(dup.action, dup.bindingIndex, NULL_BINDING));
|
|
OnRebindConflict.OnNext((new RebindContext(action, bindingIndex, path), new RebindContext(dup.action, dup.bindingIndex, dup.action.bindings[dup.bindingIndex].path)));
|
|
}
|
|
else
|
|
{
|
|
PrepareRebind(new RebindContext(action, bindingIndex, path));
|
|
}
|
|
})
|
|
.OnComplete(opc =>
|
|
{
|
|
if (debugMode) Debug.Log("[InputBindingManager] Rebind completed");
|
|
actions.Enable();
|
|
OnRebindEnd.OnNext(true);
|
|
CleanRebindOperation();
|
|
})
|
|
.OnCancel(opc =>
|
|
{
|
|
if (debugMode) Debug.Log("[InputBindingManager] Rebind cancelled");
|
|
actions.Enable();
|
|
OnRebindEnd.OnNext(false);
|
|
CleanRebindOperation();
|
|
})
|
|
.WithCancelingThrough("<Keyboard>/escape")
|
|
.Start();
|
|
}
|
|
|
|
private void CleanRebindOperation()
|
|
{
|
|
rebindOperation?.Dispose();
|
|
rebindOperation = null;
|
|
}
|
|
|
|
private bool AnyPreparedRebind(string bindingPath, InputAction currentAction, int currentIndex, out RebindContext duplicate)
|
|
{
|
|
foreach (var ctx in preparedRebinds)
|
|
{
|
|
if (ctx.overridePath == bindingPath && (ctx.action != currentAction || (ctx.action == currentAction && ctx.bindingIndex != currentIndex)))
|
|
{
|
|
duplicate = ctx;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
duplicate = null;
|
|
return false;
|
|
}
|
|
|
|
private bool AnyBindingPath(string bindingPath, InputAction currentAction, int currentIndex, out (InputAction action, int bindingIndex) duplicate)
|
|
{
|
|
foreach (var map in actionMap)
|
|
{
|
|
foreach (var actionPair in map.Value.actions)
|
|
{
|
|
foreach (var bindingPair in actionPair.Value.bindings)
|
|
{
|
|
if (actionPair.Value.action == currentAction && bindingPair.Key == currentIndex) continue;
|
|
var eff = bindingPair.Value.bindingPath.EffectivePath;
|
|
if (eff == bindingPath)
|
|
{
|
|
duplicate = (actionPair.Value.action, bindingPair.Key);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
duplicate = default;
|
|
return false;
|
|
}
|
|
|
|
private void PrepareRebind(RebindContext context)
|
|
{
|
|
preparedRebinds.RemoveAll(x => x.Equals(context));
|
|
|
|
if (string.IsNullOrEmpty(context.overridePath))
|
|
{
|
|
var bp = GetBindingPath(context.action.name, context.bindingIndex);
|
|
if (bp != null) context.overridePath = bp.bindingPath;
|
|
}
|
|
|
|
var bindingPath = GetBindingPath(context.action.name, context.bindingIndex);
|
|
if (bindingPath == null) return;
|
|
|
|
if (bindingPath.EffectivePath != context.overridePath)
|
|
{
|
|
preparedRebinds.Add(context);
|
|
isApplyPending = true;
|
|
OnRebindPrepare.OnNext(context);
|
|
if (debugMode) Debug.Log($"Prepared rebind: {context} -> {context.overridePath}");
|
|
}
|
|
}
|
|
|
|
private async Task WriteOverridesToDiskAsync()
|
|
{
|
|
try
|
|
{
|
|
var json = actions.SaveBindingOverridesAsJson();
|
|
var dir = Path.GetDirectoryName(SavePath);
|
|
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
|
using (var sw = new StreamWriter(SavePath, false)) await sw.WriteAsync(json);
|
|
if (debugMode) Debug.Log($"Overrides saved to {SavePath}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError("Failed to save overrides: " + ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task ResetToDefaultAsync()
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(defaultBindingsJson)) actions.LoadBindingOverridesFromJson(defaultBindingsJson);
|
|
else
|
|
{
|
|
foreach (var map in actionMap)
|
|
foreach (var a in map.Value.actions)
|
|
for (int b = 0; b < a.Value.action.bindings.Count; b++)
|
|
a.Value.action.RemoveBindingOverride(b);
|
|
}
|
|
|
|
RefreshBindingPathsFromActions();
|
|
await WriteOverridesToDiskAsync();
|
|
if (debugMode) Debug.Log("Reset to default and saved.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError("Failed to reset defaults: " + ex);
|
|
}
|
|
}
|
|
|
|
public static BindingPath GetBindingPath(string actionName, int bindingIndex = 0)
|
|
{
|
|
foreach (var map in Instance.actionMap)
|
|
{
|
|
if (map.Value.actions.TryGetValue(actionName, out var action))
|
|
{
|
|
if (action.bindings.TryGetValue(bindingIndex, out var binding)) return binding.bindingPath;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// choose best binding index for keyboard; if compositePartName != null then look for part
|
|
public int FindBestBindingIndexForKeyboard(InputAction action, string compositePartName = null)
|
|
{
|
|
if (action == null) return -1;
|
|
|
|
int fallbackPart = -1;
|
|
int fallbackNonComposite = -1;
|
|
|
|
for (int i = 0; i < action.bindings.Count; i++)
|
|
{
|
|
var b = action.bindings[i];
|
|
|
|
if (!string.IsNullOrEmpty(compositePartName))
|
|
{
|
|
if (!b.isPartOfComposite) continue;
|
|
if (!string.Equals(b.name, compositePartName, StringComparison.OrdinalIgnoreCase)) continue;
|
|
}
|
|
|
|
if (b.isPartOfComposite)
|
|
{
|
|
if (fallbackPart == -1) fallbackPart = i;
|
|
if (!string.IsNullOrEmpty(b.path) && b.path.StartsWith("<Keyboard>", StringComparison.OrdinalIgnoreCase)) return i;
|
|
if (!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith("<Keyboard>", StringComparison.OrdinalIgnoreCase)) return i;
|
|
}
|
|
else
|
|
{
|
|
if (fallbackNonComposite == -1) fallbackNonComposite = i;
|
|
if (!string.IsNullOrEmpty(b.path) && b.path.StartsWith("<Keyboard>", StringComparison.OrdinalIgnoreCase)) return i;
|
|
if (!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith("<Keyboard>", StringComparison.OrdinalIgnoreCase)) return i;
|
|
}
|
|
}
|
|
|
|
if (fallbackNonComposite >= 0) return fallbackNonComposite;
|
|
return fallbackPart;
|
|
}
|
|
|
|
public static InputBindingManager Instance => _instance ??= FindObjectOfType<InputBindingManager>();
|
|
private static InputBindingManager _instance;
|
|
}
|
|
}
|