com.alicizax.unity.ui.exten.../Runtime/InputGlyph/Core/InputBindingManager.cs
2026-03-26 19:55:39 +08:00

793 lines
26 KiB
C#

using System;
using System.IO;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.InputSystem;
using AlicizaX;
public sealed class InputBindingManager : MonoServiceBehaviour<AppScope>
{
private const string NULL_BINDING = "__NULL__";
private const string KEYBOARD_DEVICE = "<Keyboard>";
private const string MOUSE_DELTA = "<Mouse>/delta";
private const string MOUSE_SCROLL = "<Mouse>/scroll";
private const string MOUSE_SCROLL_X = "<Mouse>/scroll/x";
private const string MOUSE_SCROLL_Y = "<Mouse>/scroll/y";
private const string KEYBOARD_ESCAPE = "<Keyboard>/escape";
[Tooltip("InputActionAsset to manage")]
public InputActionAsset actions;
private const string FILE_NAME = "input_bindings.json";
public bool debugMode = false;
private InputActionRebindingExtensions.RebindingOperation rebindOperation;
private bool isApplyPending = false;
private string defaultBindingsJson = string.Empty;
private string cachedSavePath;
private readonly Dictionary<string, ActionMap> actionMap = new(StringComparer.Ordinal);
private readonly HashSet<RebindContext> preparedRebinds = new();
private readonly Dictionary<string, (ActionMap map, ActionMap.Action action)> actionLookup = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, (ActionMap map, ActionMap.Action action)> actionLookupById = new();
private readonly HashSet<string> ambiguousActionNames = new(StringComparer.Ordinal);
public event Action<bool, HashSet<RebindContext>> OnApply;
public event Action<RebindContext> OnRebindPrepare;
public event Action OnRebindStart;
public event Action<bool, RebindContext> OnRebindEnd;
public event Action<RebindContext, RebindContext> OnRebindConflict;
public static event Action BindingsChanged;
public IReadOnlyDictionary<string, ActionMap> ActionMaps => actionMap;
public IReadOnlyCollection<RebindContext> PreparedRebinds => preparedRebinds;
private string SavePath
{
get
{
if (!string.IsNullOrEmpty(cachedSavePath))
return cachedSavePath;
#if UNITY_EDITOR
string folder = Application.dataPath;
#else
string folder = Application.persistentDataPath;
#endif
cachedSavePath = Path.Combine(folder, FILE_NAME);
return cachedSavePath;
}
}
private void EnsureSaveDirectoryExists()
{
var directory = Path.GetDirectoryName(SavePath);
if (!Directory.Exists(directory))
Directory.CreateDirectory(directory);
}
protected override void OnInitialize()
{
if (actions == null)
{
Log.Error("InputBindingManager: InputActionAsset not assigned.");
return;
}
BuildActionMap();
try
{
defaultBindingsJson = actions.SaveBindingOverridesAsJson();
}
catch (Exception ex)
{
Log.Warning($"[InputBindingManager] Failed to save default bindings: {ex.Message}");
defaultBindingsJson = string.Empty;
}
if (File.Exists(SavePath))
{
try
{
var json = File.ReadAllText(SavePath);
if (!string.IsNullOrEmpty(json))
{
actions.LoadBindingOverridesFromJson(json);
RefreshBindingPathsFromActions();
BindingsChanged?.Invoke();
if (debugMode)
{
Log.Info($"Loaded overrides from {SavePath}");
}
}
}
catch (Exception ex)
{
Log.Error("Failed to load overrides: " + ex);
}
}
actions.Enable();
}
private void OnDestroy()
{
rebindOperation?.Dispose();
rebindOperation = null;
OnApply = null;
OnRebindPrepare = null;
OnRebindStart = null;
OnRebindEnd = null;
OnRebindConflict = null;
BindingsChanged = null;
}
private void BuildActionMap()
{
actionMap.Clear();
actionLookup.Clear();
actionLookupById.Clear();
ambiguousActionNames.Clear();
foreach (var map in actions.actionMaps)
{
var actionMapObj = new ActionMap(map);
actionMap.Add(map.name, actionMapObj);
foreach (var actionPair in actionMapObj.actions)
{
RegisterActionLookup(map.name, actionPair.Key, actionMapObj, actionPair.Value);
}
}
}
private void RegisterActionLookup(string mapName, string actionName, ActionMap map, ActionMap.Action action)
{
actionLookupById[action.action.id] = (map, action);
actionLookup[$"{mapName}/{actionName}"] = (map, action);
if (ambiguousActionNames.Contains(actionName))
{
return;
}
if (actionLookup.TryGetValue(actionName, out var existing))
{
if (existing.action.action != action.action)
{
actionLookup.Remove(actionName);
ambiguousActionNames.Add(actionName);
Log.Warning($"[InputBindingManager] Duplicate action name '{actionName}' detected. Use 'MapName/{actionName}' to resolve it.");
}
return;
}
actionLookup[actionName] = (map, action);
}
private void RefreshBindingPathsFromActions()
{
foreach (var mapPair in actionMap.Values)
{
foreach (var actionPair in mapPair.actions.Values)
{
var a = actionPair;
foreach (var bpair in a.bindings)
{
bpair.Value.bindingPath.EffectivePath = a.action.bindings[bpair.Key].effectivePath;
}
}
}
}
public sealed class ActionMap
{
public string name;
public Dictionary<string, Action> actions;
public ActionMap(InputActionMap map)
{
name = map.name;
int actionCount = map.actions.Count;
actions = new Dictionary<string, Action>(actionCount);
foreach (var action in map.actions)
{
actions.Add(action.name, new Action(action));
}
}
public sealed class Action
{
public InputAction action;
public Dictionary<int, Binding> bindings;
public Action(InputAction action)
{
this.action = action;
int count = action.bindings.Count;
bindings = new Dictionary<int, Binding>(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(
binding.name,
action.name,
binding.name,
bindingIndex,
new BindingPath(binding.path, binding.overridePath),
binding
));
}
}
public readonly struct Binding
{
public readonly string name;
public readonly string parentAction;
public readonly string compositePart;
public readonly int bindingIndex;
public readonly BindingPath bindingPath;
public readonly InputBinding inputBinding;
public Binding(string name, string parentAction, string compositePart, int bindingIndex,
BindingPath bindingPath, InputBinding inputBinding)
{
this.name = name;
this.parentAction = parentAction;
this.compositePart = compositePart;
this.bindingIndex = bindingIndex;
this.bindingPath = bindingPath;
this.inputBinding = inputBinding;
}
}
}
}
public sealed class BindingPath
{
public string bindingPath;
public string overridePath;
private event Action<string> onEffectivePathChanged;
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;
onEffectivePathChanged?.Invoke(EffectivePath);
}
}
public void SubscribeToEffectivePathChanged(Action<string> callback)
{
onEffectivePathChanged += callback;
}
public void UnsubscribeFromEffectivePathChanged(Action<string> callback)
{
onEffectivePathChanged -= callback;
}
public void Dispose()
{
onEffectivePathChanged = null;
}
}
public sealed class RebindContext
{
public InputAction action;
public int bindingIndex;
public string overridePath;
private string cachedToString;
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.id == other.action.id && bindingIndex == other.bindingIndex;
}
public override int GetHashCode()
{
unchecked
{
int hashCode = 17;
hashCode = (hashCode * 31) + (action != null ? action.id.GetHashCode() : 0);
hashCode = (hashCode * 31) + bindingIndex;
return hashCode;
}
}
public override string ToString()
{
if (cachedToString == null && action != null)
{
string mapName = action.actionMap != null ? action.actionMap.name : "<no-map>";
cachedToString = $"{mapName}/{action.name}:{bindingIndex}";
}
return cachedToString ?? "<null>";
}
}
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)
.WithControlsExcluding(MOUSE_SCROLL)
.WithControlsExcluding(MOUSE_SCROLL_X)
.WithControlsExcluding(MOUSE_SCROLL_Y);
}
rebindOperation = op
.OnApplyBinding((o, path) =>
{
RebindContext preparedContext = new RebindContext(action, bindingIndex, path);
if (AnyPreparedRebind(path, action, bindingIndex, out var existing))
{
PrepareRebind(preparedContext);
PrepareRebind(new RebindContext(existing.action, existing.bindingIndex, NULL_BINDING));
OnRebindConflict?.Invoke(preparedContext, existing);
}
else if (AnyBindingPath(path, action, bindingIndex, out var dup))
{
RebindContext conflictingContext = new RebindContext(dup.action, dup.bindingIndex, dup.action.bindings[dup.bindingIndex].path);
PrepareRebind(preparedContext);
PrepareRebind(new RebindContext(dup.action, dup.bindingIndex, NULL_BINDING));
OnRebindConflict?.Invoke(preparedContext, conflictingContext);
}
else
{
PrepareRebind(preparedContext);
}
})
.OnComplete(opc =>
{
if (debugMode)
{
Log.Info("[InputBindingManager] Rebind completed");
}
actions.Enable();
OnRebindEnd?.Invoke(true, new RebindContext(action, bindingIndex, action.bindings[bindingIndex].effectivePath));
CleanRebindOperation();
})
.OnCancel(opc =>
{
if (debugMode)
{
Log.Info("[InputBindingManager] Rebind cancelled");
}
actions.Enable();
OnRebindEnd?.Invoke(false, new RebindContext(action, bindingIndex, action.bindings[bindingIndex].effectivePath));
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.Values)
{
foreach (var actionPair in map.actions.Values)
{
bool isSameAction = actionPair.action == currentAction;
foreach (var bindingPair in actionPair.bindings)
{
// Skip if it's the same action and same binding index
if (isSameAction && bindingPair.Key == currentIndex)
continue;
if (bindingPair.Value.bindingPath.EffectivePath == bindingPath)
{
duplicate = (actionPair.action, bindingPair.Key);
return true;
}
}
}
}
duplicate = default;
return false;
}
private void PrepareRebind(RebindContext context)
{
// Remove any existing prepared state for the same action/binding pair.
preparedRebinds.Remove(context);
BindingPath bindingPath = GetBindingPath(context.action, context.bindingIndex);
if (bindingPath == null) return;
if (string.IsNullOrEmpty(context.overridePath))
{
context.overridePath = bindingPath.bindingPath;
}
if (bindingPath.EffectivePath != context.overridePath)
{
preparedRebinds.Add(context);
isApplyPending = true;
OnRebindPrepare?.Invoke(context);
if (debugMode)
{
Log.Info($"Prepared rebind: {context} -> {context.overridePath}");
}
}
}
private async Task WriteOverridesToDiskAsync()
{
try
{
var json = actions.SaveBindingOverridesAsJson();
EnsureSaveDirectoryExists();
using (var sw = new StreamWriter(SavePath, false)) await sw.WriteAsync(json);
if (debugMode)
{
Log.Info($"Overrides saved to {SavePath}");
}
}
catch (Exception ex)
{
Log.Error("Failed to save overrides: " + ex);
throw;
}
}
private bool TryGetActionRecord(string actionName, out (ActionMap map, ActionMap.Action action) result)
{
return actionLookup.TryGetValue(actionName, out result);
}
private bool TryGetActionRecord(InputAction action, out (ActionMap map, ActionMap.Action action) result)
{
if (action != null && actionLookupById.TryGetValue(action.id, out result))
{
return result.action.action == action;
}
result = default;
return false;
}
#region Public API
// 为键盘选择最佳绑定索引;如果 compositePartName != null 则查找部分
/// <summary>
/// 为键盘查找最佳的绑定索引
/// </summary>
/// <param name="action">输入操作</param>
/// <param name="compositePartName">复合部分名称(可选)</param>
/// <returns>绑定索引,未找到则返回 -1</returns>
public int FindBestBindingIndexForKeyboard(InputAction action, string compositePartName = null)
{
if (action == null) return -1;
int fallbackPart = -1;
int fallbackNonComposite = -1;
bool searchingForCompositePart = !string.IsNullOrEmpty(compositePartName);
for (int i = 0; i < action.bindings.Count; i++)
{
var b = action.bindings[i];
// 如果搜索特定的复合部分,跳过不匹配的绑定
if (searchingForCompositePart)
{
if (!b.isPartOfComposite) continue;
if (!string.Equals(b.name, compositePartName, StringComparison.OrdinalIgnoreCase)) continue;
}
// 检查此绑定是否用于键盘
bool isKeyboardBinding = (!string.IsNullOrEmpty(b.path) && b.path.StartsWith(KEYBOARD_DEVICE, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith(KEYBOARD_DEVICE, StringComparison.OrdinalIgnoreCase));
if (b.isPartOfComposite)
{
if (fallbackPart == -1) fallbackPart = i;
if (isKeyboardBinding) return i;
}
else
{
if (fallbackNonComposite == -1) fallbackNonComposite = i;
if (isKeyboardBinding) return i;
}
}
return fallbackNonComposite >= 0 ? fallbackNonComposite : fallbackPart;
}
/// <summary>
/// 根据操作名称获取输入操作
/// </summary>
/// <param name="actionName">操作名称</param>
/// <returns>输入操作,未找到则返回 null</returns>
public static InputAction Action(string actionName)
{
var instance= AppServices.Require<InputBindingManager>();
if (instance.TryGetAction(actionName, out InputAction action))
{
return action;
}
if (instance.ambiguousActionNames.Contains(actionName))
{
Log.Error($"[InputBindingManager] Action name '{actionName}' is ambiguous. Use 'MapName/{actionName}' instead.");
return null;
}
Log.Error($"[InputBindingManager] Could not find action '{actionName}'");
return null;
}
public bool TryGetAction(string actionName, out InputAction action)
{
if (string.IsNullOrWhiteSpace(actionName))
{
action = null;
return false;
}
if (actionLookup.TryGetValue(actionName, out var result))
{
action = result.action.action;
return true;
}
action = null;
return false;
}
/// <summary>
/// 开始重新绑定指定的输入操作
/// </summary>
/// <param name="actionName">操作名称</param>
/// <param name="compositePartName">复合部分名称(可选)</param>
public void StartRebind(string actionName, string compositePartName = null)
{
var action = Action(actionName);
if (action == null) return;
// 自动决定 bindingIndex 和 deviceMatch
int bindingIndex = FindBestBindingIndexForKeyboard(action, compositePartName);
if (bindingIndex < 0)
{
Log.Error($"[InputBindingManager] No suitable binding found for action '{actionName}' (part={compositePartName ?? "<null>"})");
return;
}
actions.Disable();
PerformInteractiveRebinding(action, bindingIndex, KEYBOARD_DEVICE, true);
OnRebindStart?.Invoke();
if (debugMode)
{
Log.Info("[InputBindingManager] Rebind started");
}
}
/// <summary>
/// 取消当前的重新绑定操作
/// </summary>
public void CancelRebind() => rebindOperation?.Cancel();
/// <summary>
/// 确认并应用准备好的重新绑定
/// </summary>
/// <param name="clearConflicts">是否清除冲突</param>
/// <returns>是否成功应用</returns>
public async Task<bool> ConfirmApply(bool clearConflicts = true)
{
if (!isApplyPending) return false;
try
{
// 在清除之前创建准备好的重绑定的副本
HashSet<RebindContext> appliedContexts = OnApply != null
? new HashSet<RebindContext>(preparedRebinds)
: null;
foreach (var ctx in preparedRebinds)
{
if (!string.IsNullOrEmpty(ctx.overridePath))
{
if (ctx.overridePath == NULL_BINDING)
{
ctx.action.RemoveBindingOverride(ctx.bindingIndex);
}
else
{
ctx.action.ApplyBindingOverride(ctx.bindingIndex, ctx.overridePath);
}
}
var bp = GetBindingPath(ctx.action, ctx.bindingIndex);
if (bp != null)
{
bp.EffectivePath = (ctx.overridePath == NULL_BINDING) ? string.Empty : ctx.overridePath;
}
}
preparedRebinds.Clear();
await WriteOverridesToDiskAsync();
BindingsChanged?.Invoke();
OnApply?.Invoke(true, appliedContexts);
isApplyPending = false;
if (debugMode)
{
Log.Info("[InputBindingManager] Apply confirmed and saved.");
}
return true;
}
catch (Exception ex)
{
Log.Error("[InputBindingManager] Failed to apply binds: " + ex);
OnApply?.Invoke(false, null);
return false;
}
}
/// <summary>
/// 丢弃准备好的重新绑定
/// </summary>
public void DiscardPrepared()
{
if (!isApplyPending) return;
// 在清除之前创建准备好的重绑定的副本(用于事件通知)
HashSet<RebindContext> discardedContexts = OnApply != null
? new HashSet<RebindContext>(preparedRebinds)
: null;
preparedRebinds.Clear();
isApplyPending = false;
OnApply?.Invoke(false, discardedContexts);
if (debugMode)
{
Log.Info("[InputBindingManager] Prepared rebinds discarded.");
}
}
/// <summary>
/// 重置所有绑定到默认值
/// </summary>
public async Task ResetToDefaultAsync()
{
try
{
if (!string.IsNullOrEmpty(defaultBindingsJson))
{
actions.LoadBindingOverridesFromJson(defaultBindingsJson);
}
else
{
foreach (var map in actionMap.Values)
{
foreach (var a in map.actions.Values)
{
for (int b = 0; b < a.action.bindings.Count; b++)
{
a.action.RemoveBindingOverride(b);
}
}
}
}
RefreshBindingPathsFromActions();
await WriteOverridesToDiskAsync();
BindingsChanged?.Invoke();
if (debugMode)
{
Log.Info("Reset to default and saved.");
}
}
catch (Exception ex)
{
Log.Error("Failed to reset defaults: " + ex);
}
}
/// <summary>
/// 获取指定操作的绑定路径
/// </summary>
/// <param name="actionName">操作名称</param>
/// <param name="bindingIndex">绑定索引</param>
/// <returns>绑定路径,未找到则返回 null</returns>
public BindingPath GetBindingPath(string actionName, int bindingIndex = 0)
{
if (TryGetActionRecord(actionName, out var result)
&& result.action.bindings.TryGetValue(bindingIndex, out var binding))
{
return binding.bindingPath;
}
return null;
}
public BindingPath GetBindingPath(InputAction action, int bindingIndex = 0)
{
if (action == null) return null;
if (TryGetActionRecord(action, out var result)
&& result.action.bindings.TryGetValue(bindingIndex, out var binding))
{
return binding.bindingPath;
}
return null;
}
#endregion
}