AlicizaX/Client/Assets/InputGlyph/InputBindingManager.cs
2025-12-17 20:03:29 +08:00

534 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()
{
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;
}
}