2026-03-20 16:50:30 +08:00
|
|
|
using System;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
using UnityEngine.InputSystem;
|
|
|
|
|
using AlicizaX;
|
|
|
|
|
|
2026-03-26 19:50:52 +08:00
|
|
|
public sealed class InputBindingManager : MonoServiceBehaviour<AppScope>
|
2026-03-20 16:50:30 +08:00
|
|
|
{
|
2026-03-26 16:12:50 +08:00
|
|
|
private const string NULL_BINDING = "__NULL__";
|
2026-03-20 16:50:30 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
private InputActionRebindingExtensions.RebindingOperation rebindOperation;
|
2026-03-20 16:50:30 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
private string SavePath
|
2026-03-20 16:50:30 +08:00
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-26 19:55:39 +08:00
|
|
|
protected override void OnInitialize()
|
2026-03-26 16:12:50 +08:00
|
|
|
{
|
2026-03-20 16:50:30 +08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
private void OnDestroy()
|
2026-03-20 16:50:30 +08:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
#region Public API
|
2026-03-20 16:50:30 +08:00
|
|
|
|
|
|
|
|
// 为键盘选择最佳绑定索引;如果 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// 根据操作名称获取输入操作
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="actionName">操作名称</param>
|
|
|
|
|
/// <returns>输入操作,未找到则返回 null</returns>
|
|
|
|
|
public static InputAction Action(string actionName)
|
2026-03-20 16:50:30 +08:00
|
|
|
{
|
2026-03-26 16:12:50 +08:00
|
|
|
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
|
2026-03-20 16:50:30 +08:00
|
|
|
{
|
2026-03-26 16:12:50 +08:00
|
|
|
if (!string.IsNullOrEmpty(defaultBindingsJson))
|
2026-03-20 16:50:30 +08:00
|
|
|
{
|
2026-03-26 16:12:50 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 16:50:30 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
RefreshBindingPathsFromActions();
|
|
|
|
|
await WriteOverridesToDiskAsync();
|
|
|
|
|
BindingsChanged?.Invoke();
|
|
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Log.Info("Reset to default and saved.");
|
|
|
|
|
}
|
2026-03-20 16:50:30 +08:00
|
|
|
}
|
2026-03-26 16:12:50 +08:00
|
|
|
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;
|
2026-03-20 16:50:30 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:12:50 +08:00
|
|
|
#endregion
|
2026-03-20 16:50:30 +08:00
|
|
|
}
|