2025-12-09 20:31:44 +08:00
|
|
|
// InputBindingManager.cs
|
2025-12-10 17:38:31 +08:00
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
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;
|
2026-03-09 20:38:15 +08:00
|
|
|
using AlicizaX;
|
|
|
|
|
|
|
|
|
|
using Cysharp.Threading.Tasks;
|
2025-12-09 20:31:44 +08:00
|
|
|
using RxUnit = System.Reactive.Unit;
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
|
|
|
|
|
public class InputBindingManager : MonoSingleton<InputBindingManager>
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
public const string NULL_BINDING = "__NULL__";
|
2026-03-09 20:38:15 +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";
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
[Tooltip("InputActionAsset to manage")]
|
|
|
|
|
public InputActionAsset actions;
|
2025-12-10 17:38:31 +08:00
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
public string fileName = "input_bindings.json";
|
|
|
|
|
public bool debugMode = false;
|
|
|
|
|
|
|
|
|
|
public Dictionary<string, ActionMap> actionMap = new Dictionary<string, ActionMap>();
|
2026-03-09 20:38:15 +08:00
|
|
|
public HashSet<RebindContext> preparedRebinds = new HashSet<RebindContext>();
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
internal InputActionRebindingExtensions.RebindingOperation rebindOperation;
|
|
|
|
|
private bool isApplyPending = false;
|
|
|
|
|
private string defaultBindingsJson = string.Empty;
|
2026-03-09 20:38:15 +08:00
|
|
|
private string cachedSavePath;
|
|
|
|
|
private Dictionary<string, (ActionMap map, ActionMap.Action action)> actionLookup = new Dictionary<string, (ActionMap, ActionMap.Action)>();
|
2025-12-09 20:31:44 +08:00
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public readonly ReplaySubject<RxUnit> OnInputsInit = new ReplaySubject<RxUnit>(1);
|
|
|
|
|
public readonly Subject<(bool success, HashSet<RebindContext> appliedContexts)> OnApply = new Subject<(bool, HashSet<RebindContext>)>();
|
|
|
|
|
public readonly Subject<RebindContext> OnRebindPrepare = new Subject<RebindContext>();
|
|
|
|
|
public readonly Subject<RxUnit> OnRebindStart = new Subject<RxUnit>();
|
|
|
|
|
public readonly Subject<(bool success, RebindContext context)> OnRebindEnd = new Subject<(bool, RebindContext)>();
|
|
|
|
|
public readonly Subject<(RebindContext prepared, RebindContext conflict)> OnRebindConflict = new Subject<(RebindContext, RebindContext)>();
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
public string SavePath
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (!string.IsNullOrEmpty(cachedSavePath))
|
|
|
|
|
return cachedSavePath;
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
#if UNITY_EDITOR
|
|
|
|
|
string folder = Application.dataPath;
|
|
|
|
|
#else
|
|
|
|
|
string folder = Application.persistentDataPath;
|
|
|
|
|
#endif
|
2026-03-09 20:38:15 +08:00
|
|
|
cachedSavePath = Path.Combine(folder, fileName);
|
|
|
|
|
return cachedSavePath;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
private void EnsureSaveDirectoryExists()
|
|
|
|
|
{
|
|
|
|
|
var directory = Path.GetDirectoryName(SavePath);
|
|
|
|
|
if (!Directory.Exists(directory))
|
|
|
|
|
Directory.CreateDirectory(directory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override void Awake()
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (_instance != null && _instance != this)
|
|
|
|
|
{
|
|
|
|
|
Destroy(gameObject);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_instance = this;
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
if (actions == null)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogError("InputBindingManager: InputActionAsset not assigned.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BuildActionMap();
|
|
|
|
|
|
2025-12-10 17:38:31 +08:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
defaultBindingsJson = actions.SaveBindingOverridesAsJson();
|
|
|
|
|
}
|
2026-03-09 20:38:15 +08:00
|
|
|
catch (Exception ex)
|
2025-12-10 17:38:31 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
Debug.LogWarning($"[InputBindingManager] Failed to save default bindings: {ex.Message}");
|
2025-12-10 17:38:31 +08:00
|
|
|
defaultBindingsJson = string.Empty;
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
if (File.Exists(SavePath))
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var json = File.ReadAllText(SavePath);
|
|
|
|
|
if (!string.IsNullOrEmpty(json))
|
|
|
|
|
{
|
|
|
|
|
actions.LoadBindingOverridesFromJson(json);
|
|
|
|
|
RefreshBindingPathsFromActions();
|
2026-03-09 20:38:15 +08:00
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Loaded overrides from {SavePath}");
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogError("Failed to load overrides: " + ex);
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
OnInputsInit.OnNext(RxUnit.Default);
|
|
|
|
|
actions.Enable();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
protected override void OnDestroy()
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (_instance == this)
|
|
|
|
|
{
|
|
|
|
|
_instance = null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
rebindOperation?.Dispose();
|
|
|
|
|
rebindOperation = null;
|
|
|
|
|
|
|
|
|
|
OnInputsInit?.OnCompleted();
|
|
|
|
|
OnApply?.OnCompleted();
|
|
|
|
|
OnRebindPrepare?.OnCompleted();
|
|
|
|
|
OnRebindStart?.OnCompleted();
|
|
|
|
|
OnRebindEnd?.OnCompleted();
|
|
|
|
|
OnRebindConflict?.OnCompleted();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void BuildActionMap()
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
// Pre-allocate with known capacity to avoid resizing
|
|
|
|
|
int mapCount = actions.actionMaps.Count;
|
2025-12-09 20:31:44 +08:00
|
|
|
actionMap.Clear();
|
2026-03-09 20:38:15 +08:00
|
|
|
actionLookup.Clear();
|
|
|
|
|
|
|
|
|
|
// Estimate total action count for better allocation
|
|
|
|
|
int estimatedActionCount = 0;
|
|
|
|
|
foreach (var map in actions.actionMaps)
|
|
|
|
|
{
|
|
|
|
|
estimatedActionCount += map.actions.Count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure capacity to avoid rehashing
|
|
|
|
|
if (actionMap.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
actionMap = new Dictionary<string, ActionMap>(mapCount);
|
|
|
|
|
actionLookup = new Dictionary<string, (ActionMap, ActionMap.Action)>(estimatedActionCount);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
foreach (var map in actions.actionMaps)
|
2026-03-09 20:38:15 +08:00
|
|
|
{
|
|
|
|
|
var actionMapObj = new ActionMap(map);
|
|
|
|
|
actionMap.Add(map.name, actionMapObj);
|
|
|
|
|
|
|
|
|
|
// Build lookup dictionary for O(1) action access
|
|
|
|
|
foreach (var actionPair in actionMapObj.actions)
|
|
|
|
|
{
|
|
|
|
|
actionLookup[actionPair.Key] = (actionMapObj, actionPair.Value);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void RefreshBindingPathsFromActions()
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
foreach (var mapPair in actionMap.Values)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
foreach (var actionPair in mapPair.actions.Values)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
var a = actionPair;
|
2025-12-09 20:31:44 +08:00
|
|
|
foreach (var bpair in a.bindings)
|
|
|
|
|
{
|
|
|
|
|
bpair.Value.bindingPath.EffectivePath = a.action.bindings[bpair.Key].effectivePath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public sealed class ActionMap
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
public string name;
|
2026-03-09 20:38:15 +08:00
|
|
|
public Dictionary<string, Action> actions;
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
public ActionMap(InputActionMap map)
|
|
|
|
|
{
|
|
|
|
|
name = map.name;
|
2026-03-09 20:38:15 +08:00
|
|
|
int actionCount = map.actions.Count;
|
|
|
|
|
actions = new Dictionary<string, Action>(actionCount);
|
|
|
|
|
foreach (var action in map.actions)
|
|
|
|
|
{
|
|
|
|
|
actions.Add(action.name, new Action(action));
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public sealed class Action
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
public InputAction action;
|
2026-03-09 20:38:15 +08:00
|
|
|
public Dictionary<int, Binding> bindings;
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
public Action(InputAction action)
|
|
|
|
|
{
|
|
|
|
|
this.action = action;
|
|
|
|
|
int count = action.bindings.Count;
|
2026-03-09 20:38:15 +08:00
|
|
|
bindings = new Dictionary<int, Binding>(count);
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
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)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
bindings.Add(bindingIndex, new Binding(
|
|
|
|
|
binding.name,
|
|
|
|
|
action.name,
|
|
|
|
|
binding.name,
|
|
|
|
|
bindingIndex,
|
|
|
|
|
binding.groups?.Split(InputBinding.Separator) ?? Array.Empty<string>(),
|
|
|
|
|
new BindingPath(binding.path, binding.overridePath),
|
|
|
|
|
binding
|
|
|
|
|
));
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public readonly struct Binding
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
public readonly string name;
|
|
|
|
|
public readonly string parentAction;
|
|
|
|
|
public readonly string compositePart;
|
|
|
|
|
public readonly int bindingIndex;
|
|
|
|
|
public readonly string[] group;
|
|
|
|
|
public readonly BindingPath bindingPath;
|
|
|
|
|
public readonly InputBinding inputBinding;
|
|
|
|
|
|
|
|
|
|
public Binding(string name, string parentAction, string compositePart, int bindingIndex,
|
|
|
|
|
string[] group, BindingPath bindingPath, InputBinding inputBinding)
|
|
|
|
|
{
|
|
|
|
|
this.name = name;
|
|
|
|
|
this.parentAction = parentAction;
|
|
|
|
|
this.compositePart = compositePart;
|
|
|
|
|
this.bindingIndex = bindingIndex;
|
|
|
|
|
this.group = group;
|
|
|
|
|
this.bindingPath = bindingPath;
|
|
|
|
|
this.inputBinding = inputBinding;
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class BindingPath
|
|
|
|
|
{
|
|
|
|
|
public string bindingPath;
|
|
|
|
|
public string overridePath;
|
2026-03-09 20:38:15 +08:00
|
|
|
private Subject<RxUnit> observer;
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
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;
|
2026-03-09 20:38:15 +08:00
|
|
|
observer?.OnNext(RxUnit.Default);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public IObservable<string> EffectivePathObservable
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
observer ??= new Subject<RxUnit>();
|
|
|
|
|
return observer.Select(_ => EffectivePath);
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
observer?.OnCompleted();
|
|
|
|
|
observer?.Dispose();
|
|
|
|
|
observer = null;
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public sealed class RebindContext
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
public InputAction action;
|
|
|
|
|
public int bindingIndex;
|
|
|
|
|
public string overridePath;
|
2026-03-09 20:38:15 +08:00
|
|
|
private string cachedToString;
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
public RebindContext(InputAction action, int bindingIndex, string overridePath)
|
|
|
|
|
{
|
2025-12-10 17:38:31 +08:00
|
|
|
this.action = action;
|
|
|
|
|
this.bindingIndex = bindingIndex;
|
|
|
|
|
this.overridePath = overridePath;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-09 20:38:15 +08:00
|
|
|
|
|
|
|
|
public override string ToString()
|
|
|
|
|
{
|
|
|
|
|
if (cachedToString == null && action != null)
|
|
|
|
|
{
|
|
|
|
|
cachedToString = $"{action.name}:{bindingIndex}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cachedToString ?? "<null>";
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------- Public API ---------------- */
|
|
|
|
|
|
|
|
|
|
public static InputAction Action(string actionName)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
var instance = Instance;
|
|
|
|
|
if (instance == null) return null;
|
|
|
|
|
|
|
|
|
|
if (instance.actionLookup.TryGetValue(actionName, out var result))
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
return result.action.action;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
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();
|
2026-03-09 20:38:15 +08:00
|
|
|
Instance.PerformInteractiveRebinding(action, bindingIndex, KEYBOARD_DEVICE, true);
|
2025-12-09 20:31:44 +08:00
|
|
|
Instance.OnRebindStart.OnNext(RxUnit.Default);
|
2026-03-09 20:38:15 +08:00
|
|
|
if (Instance.debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log("[InputBindingManager] Rebind started");
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static void CancelRebind() => Instance.rebindOperation?.Cancel();
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public static async UniTask<bool> ConfirmApply(bool clearConflicts = true)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
if (!Instance.isApplyPending) return false;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
// Create a copy of the prepared rebinds before clearing
|
|
|
|
|
var appliedContexts = new HashSet<RebindContext>(Instance.preparedRebinds);
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
foreach (var ctx in Instance.preparedRebinds)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (!string.IsNullOrEmpty(ctx.overridePath))
|
2025-12-10 17:38:31 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (ctx.overridePath == NULL_BINDING)
|
|
|
|
|
{
|
|
|
|
|
ctx.action.RemoveBindingOverride(ctx.bindingIndex);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
ctx.action.ApplyBindingOverride(ctx.bindingIndex, ctx.overridePath);
|
|
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
var bp = GetBindingPath(ctx.action.name, ctx.bindingIndex);
|
2026-03-09 20:38:15 +08:00
|
|
|
if (bp != null)
|
|
|
|
|
{
|
|
|
|
|
bp.EffectivePath = (ctx.overridePath == NULL_BINDING) ? string.Empty : ctx.overridePath;
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Instance.preparedRebinds.Clear();
|
|
|
|
|
await Instance.WriteOverridesToDiskAsync();
|
2026-03-09 20:38:15 +08:00
|
|
|
Instance.OnApply.OnNext((true, appliedContexts));
|
2025-12-09 20:31:44 +08:00
|
|
|
Instance.isApplyPending = false;
|
2026-03-09 20:38:15 +08:00
|
|
|
if (Instance.debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log("[InputBindingManager] Apply confirmed and saved.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogError("[InputBindingManager] Failed to apply binds: " + ex);
|
2026-03-09 20:38:15 +08:00
|
|
|
Instance.OnApply.OnNext((false, new HashSet<RebindContext>()));
|
2025-12-09 20:31:44 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static void DiscardPrepared()
|
|
|
|
|
{
|
|
|
|
|
if (!Instance.isApplyPending) return;
|
2026-03-09 20:38:15 +08:00
|
|
|
|
|
|
|
|
// Create a copy of the prepared rebinds before clearing (for event notification)
|
|
|
|
|
var discardedContexts = new HashSet<RebindContext>(Instance.preparedRebinds);
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
Instance.preparedRebinds.Clear();
|
|
|
|
|
Instance.isApplyPending = false;
|
2026-03-09 20:38:15 +08:00
|
|
|
Instance.OnApply.OnNext((false, discardedContexts));
|
|
|
|
|
if (Instance.debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log("[InputBindingManager] Prepared rebinds discarded.");
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void PerformInteractiveRebinding(InputAction action, int bindingIndex, string deviceMatchPath = null, bool excludeMouseMovementAndScroll = true)
|
|
|
|
|
{
|
|
|
|
|
var op = action.PerformInteractiveRebinding(bindingIndex);
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
if (!string.IsNullOrEmpty(deviceMatchPath))
|
|
|
|
|
{
|
|
|
|
|
op = op.WithControlsHavingToMatchPath(deviceMatchPath);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
if (excludeMouseMovementAndScroll)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
op = op.WithControlsExcluding(MOUSE_DELTA)
|
|
|
|
|
.WithControlsExcluding(MOUSE_SCROLL)
|
|
|
|
|
.WithControlsExcluding(MOUSE_SCROLL_X)
|
|
|
|
|
.WithControlsExcluding(MOUSE_SCROLL_Y);
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 =>
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log("[InputBindingManager] Rebind completed");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
actions.Enable();
|
2026-03-09 20:38:15 +08:00
|
|
|
OnRebindEnd.OnNext((true, new RebindContext(action, bindingIndex, action.bindings[bindingIndex].effectivePath)));
|
2025-12-09 20:31:44 +08:00
|
|
|
CleanRebindOperation();
|
|
|
|
|
})
|
|
|
|
|
.OnCancel(opc =>
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log("[InputBindingManager] Rebind cancelled");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
actions.Enable();
|
2026-03-09 20:38:15 +08:00
|
|
|
OnRebindEnd.OnNext((false, new RebindContext(action, bindingIndex, action.bindings[bindingIndex].effectivePath)));
|
2025-12-09 20:31:44 +08:00
|
|
|
CleanRebindOperation();
|
|
|
|
|
})
|
2026-03-09 20:38:15 +08:00
|
|
|
.WithCancelingThrough(KEYBOARD_ESCAPE)
|
2025-12-09 20:31:44 +08:00
|
|
|
.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)))
|
|
|
|
|
{
|
2025-12-10 17:38:31 +08:00
|
|
|
duplicate = ctx;
|
|
|
|
|
return true;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
|
|
|
|
|
duplicate = null;
|
|
|
|
|
return false;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool AnyBindingPath(string bindingPath, InputAction currentAction, int currentIndex, out (InputAction action, int bindingIndex) duplicate)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
foreach (var map in actionMap.Values)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
foreach (var actionPair in map.actions.Values)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
bool isSameAction = actionPair.action == currentAction;
|
|
|
|
|
|
|
|
|
|
foreach (var bindingPair in actionPair.bindings)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
// Skip if it's the same action and same binding index
|
|
|
|
|
if (isSameAction && bindingPair.Key == currentIndex)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
if (bindingPair.Value.bindingPath.EffectivePath == bindingPath)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
duplicate = (actionPair.action, bindingPair.Key);
|
2025-12-09 20:31:44 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
|
|
|
|
|
duplicate = default;
|
|
|
|
|
return false;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void PrepareRebind(RebindContext context)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
// Remove existing rebind for same action/binding if exists
|
|
|
|
|
preparedRebinds.Remove(context);
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
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);
|
2026-03-09 20:38:15 +08:00
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Prepared rebind: {context} -> {context.overridePath}");
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
private async UniTask WriteOverridesToDiskAsync()
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var json = actions.SaveBindingOverridesAsJson();
|
2026-03-09 20:38:15 +08:00
|
|
|
EnsureSaveDirectoryExists();
|
2025-12-09 20:31:44 +08:00
|
|
|
using (var sw = new StreamWriter(SavePath, false)) await sw.WriteAsync(json);
|
2026-03-09 20:38:15 +08:00
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Overrides saved to {SavePath}");
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogError("Failed to save overrides: " + ex);
|
|
|
|
|
throw;
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
public async UniTask ResetToDefaultAsync()
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (!string.IsNullOrEmpty(defaultBindingsJson))
|
|
|
|
|
{
|
|
|
|
|
actions.LoadBindingOverridesFromJson(defaultBindingsJson);
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
else
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RefreshBindingPathsFromActions();
|
|
|
|
|
await WriteOverridesToDiskAsync();
|
2026-03-09 20:38:15 +08:00
|
|
|
if (debugMode)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log("Reset to default and saved.");
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogError("Failed to reset defaults: " + ex);
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static BindingPath GetBindingPath(string actionName, int bindingIndex = 0)
|
|
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
var instance = Instance;
|
|
|
|
|
if (instance == null) return null;
|
|
|
|
|
|
|
|
|
|
if (instance.actionLookup.TryGetValue(actionName, out var result))
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
if (result.action.bindings.TryGetValue(bindingIndex, out var binding))
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
2026-03-09 20:38:15 +08:00
|
|
|
return binding.bindingPath;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-10 17:38:31 +08:00
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
// 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;
|
2026-03-09 20:38:15 +08:00
|
|
|
bool searchingForCompositePart = !string.IsNullOrEmpty(compositePartName);
|
2025-12-09 20:31:44 +08:00
|
|
|
|
|
|
|
|
for (int i = 0; i < action.bindings.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
var b = action.bindings[i];
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
// If searching for a specific composite part, skip non-matching bindings
|
|
|
|
|
if (searchingForCompositePart)
|
2025-12-09 20:31:44 +08:00
|
|
|
{
|
|
|
|
|
if (!b.isPartOfComposite) continue;
|
|
|
|
|
if (!string.Equals(b.name, compositePartName, StringComparison.OrdinalIgnoreCase)) continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
// Check if this binding is for keyboard
|
|
|
|
|
bool isKeyboardBinding = (!string.IsNullOrEmpty(b.path) && b.path.StartsWith(KEYBOARD_DEVICE, StringComparison.OrdinalIgnoreCase)) ||
|
|
|
|
|
(!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith(KEYBOARD_DEVICE, StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
|
2025-12-09 20:31:44 +08:00
|
|
|
if (b.isPartOfComposite)
|
|
|
|
|
{
|
|
|
|
|
if (fallbackPart == -1) fallbackPart = i;
|
2026-03-09 20:38:15 +08:00
|
|
|
if (isKeyboardBinding) return i;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
if (fallbackNonComposite == -1) fallbackNonComposite = i;
|
2026-03-09 20:38:15 +08:00
|
|
|
if (isKeyboardBinding) return i;
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:38:15 +08:00
|
|
|
return fallbackNonComposite >= 0 ? fallbackNonComposite : fallbackPart;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static InputBindingManager Instance
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
if (_instance == null)
|
|
|
|
|
{
|
|
|
|
|
_instance = FindObjectOfType<InputBindingManager>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return _instance;
|
|
|
|
|
}
|
2025-12-09 20:31:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static InputBindingManager _instance;
|
|
|
|
|
}
|
2026-03-09 20:38:15 +08:00
|
|
|
|