using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; using UnityGameFramework.Runtime; namespace SepCore.InputModule.Runtime { [DisallowMultipleComponent] [AddComponentMenu("Input Module")] public sealed class InputModuleComponent : GameFrameworkComponent { private const string DefaultBindingOverrideSettingKey = "SepCore.InputModule.BindingOverrides"; [SerializeField] private InputActionAsset _inputActionsAsset = null; [SerializeField] private InputContextId _startupContext = InputContextId.None; [SerializeField] private bool _loadBindingOverridesOnInit = true; [SerializeField] private bool _saveBindingOverridesOnDestroy = false; [SerializeField] private string _bindingOverrideSettingKey = DefaultBindingOverrideSettingKey; private readonly Dictionary> _listeners = new Dictionary>(); private readonly Dictionary _actionMaps = new Dictionary(); private readonly InputContextStack _contextStack = new InputContextStack(); private InputActionAsset _runtimeActions = null; private SettingComponent _settingComponent = null; private bool _isInitialized = false; private bool _onInitCalled = false; private bool _ownsRuntimeActions = false; private InputActionRebindingExtensions.RebindingOperation _activeRebindOperation; private bool _rebindCanceledByUser; private IInputPromptMap _promptMap; public event Action CommandTriggered; public event Action DeviceKindChanged; public event Action ContextChanged; public event Action RebindCompleted; public InputDeviceKind CurrentDeviceKind { get; private set; } = InputDeviceKind.Unknown; public InputContextId CurrentContext => _contextStack.Current; public bool IsInitialized => _isInitialized; public bool IsRebinding => _activeRebindOperation != null; public IInputPromptMap PromptMap { get { if (_promptMap == null) { _promptMap = new InputModuleDefaultPromptMap(_runtimeActions); } return _promptMap; } set => _promptMap = value; } public InputActionAsset RuntimeActions { get { EnsureInitialized(); return _runtimeActions; } } protected override void Awake() { base.Awake(); } private void OnEnable() { if (_isInitialized) { ApplyContextState(); SubscribeToGlobalDeviceTracking(); } } private void OnDisable() { UnsubscribeFromGlobalDeviceTracking(); _runtimeActions?.Disable(); } private void OnDestroy() { if (!_isInitialized) { return; } CancelRebinding(); if (_saveBindingOverridesOnDestroy) { SaveBindingOverrides(); } UnsubscribeFromGlobalDeviceTracking(); UnsubscribeFromActions(); if (_ownsRuntimeActions && _runtimeActions != null) { if (Application.isPlaying) { Destroy(_runtimeActions); } else { DestroyImmediate(_runtimeActions); } } _runtimeActions = null; _actionMaps.Clear(); _listeners.Clear(); _isInitialized = false; _onInitCalled = false; } public void RegisterListener(InputActionId actionId, Action listener) { if (listener == null) { return; } EnsureInitialized(); _listeners[actionId] = _listeners.TryGetValue(actionId, out Action existing) ? existing + listener : listener; } public bool TryGetPrompt(InputActionId actionId, out InputPrompt prompt) { return PromptMap.TryGetPrompt(actionId, CurrentDeviceKind, out prompt); } public void InjectCommand(InputCommand command) { if (!_isInitialized) { return; } CommandTriggered?.Invoke(command); if (_listeners.TryGetValue(command.ActionId, out Action listener)) { listener?.Invoke(command); } } public void ForceDeviceKind(InputDeviceKind deviceKind) { if (deviceKind == InputDeviceKind.Unknown || deviceKind == CurrentDeviceKind) { return; } InputDeviceKind previous = CurrentDeviceKind; CurrentDeviceKind = deviceKind; Log.Debug("[InputModule] Device changed (forced): {0} -> {1}", previous, deviceKind); DeviceKindChanged?.Invoke(CurrentDeviceKind); } public void UnregisterListener(InputActionId actionId, Action listener) { if (listener == null || !_listeners.TryGetValue(actionId, out Action existing)) { return; } existing -= listener; if (existing == null) { _listeners.Remove(actionId); return; } _listeners[actionId] = existing; } public void OnInit() { if (_isInitialized) { Log.Debug("[InputModule] OnInit skipped - already initialized."); return; } _onInitCalled = true; EnsureInitialized(); ApplyContextState(); } public void SetContext(InputContextId context) { EnsureInitialized(); if (_contextStack.Set(context)) { ApplyContextState(); NotifyContextChanged(); Log.Debug("[InputModule] SetContext -> {0}", context); } } public void SetUIContext() { SetContext(InputContextId.UI); } public void SetGameplayExploreContext() { SetContext(InputContextId.GameplayExplore); } public void PushContext(InputContextId context) { EnsureInitialized(); if (_contextStack.Push(context)) { ApplyContextState(); NotifyContextChanged(); Log.Debug("[InputModule] PushContext -> {0} (stack depth: {1})", context, _contextStack.Count); } } public bool PopContext() { EnsureInitialized(); if (!_contextStack.Pop(out _)) { return false; } ApplyContextState(); NotifyContextChanged(); Log.Debug("[InputModule] PopContext -> {0} (stack depth: {1})", _contextStack.Current, _contextStack.Count); return true; } public void ClearContexts() { EnsureInitialized(); if (_contextStack.Clear()) { ApplyContextState(); NotifyContextChanged(); Log.Debug("[InputModule] ClearContexts"); } } public bool StartRebinding( InputContextId contextId, InputActionId actionId, int bindingIndex, float timeoutSeconds = 5f, InputDeviceKind expectedDeviceKind = InputDeviceKind.Unknown) { EnsureInitialized(); if (_activeRebindOperation != null) { return false; } if (!TryGetAction(contextId, actionId, out InputAction action)) { return false; } if (bindingIndex < 0 || bindingIndex >= action.bindings.Count) { return false; } InputBinding binding = action.bindings[bindingIndex]; string previousEffectivePath = binding.effectivePath; string previousOverridePath = binding.overridePath; _rebindCanceledByUser = false; PushContext(InputContextId.Rebind); try { DisableActionForRebind(action); _activeRebindOperation = CreateRebindOperation( action, contextId, actionId, bindingIndex, timeoutSeconds, expectedDeviceKind, previousEffectivePath, previousOverridePath); _activeRebindOperation.Start(); } catch { _activeRebindOperation?.Dispose(); _activeRebindOperation = null; if (_contextStack.Current == InputContextId.Rebind) { PopContext(); } throw; } RebindCompleted?.Invoke(new RebindResult(RebindStatus.Started, contextId, actionId, bindingIndex, previousEffectivePath, previousEffectivePath)); return true; } public void CancelRebinding() { if (_activeRebindOperation == null) { return; } _rebindCanceledByUser = true; _activeRebindOperation.Cancel(); } public void ResetBindingToDefault(InputContextId contextId, InputActionId actionId, int bindingIndex, bool saveAfterReset = false) { EnsureInitialized(); if (!TryGetAction(contextId, actionId, out InputAction action)) { return; } if (bindingIndex < 0 || bindingIndex >= action.bindings.Count) { return; } action.RemoveBindingOverride(bindingIndex); if (saveAfterReset) { SaveBindingOverrides(); } } public void ResetActionToDefaults(InputContextId contextId, InputActionId actionId, bool saveAfterReset = false) { EnsureInitialized(); if (!TryGetAction(contextId, actionId, out InputAction action)) { return; } action.RemoveAllBindingOverrides(); if (saveAfterReset) { SaveBindingOverrides(); } } public void ResetAllBindingsToDefaults(bool saveAfterReset = false) { EnsureInitialized(); _runtimeActions.RemoveAllBindingOverrides(); if (saveAfterReset) { SaveBindingOverrides(); } } public InputBindingSnapshot SaveBindingOverrides() { EnsureInitialized(); string overridesJson = _runtimeActions.SaveBindingOverridesAsJson(); string storedData = BindingOverridePersistence.Serialize(overridesJson); SettingComponent settingComponent = GetSettingComponent(); if (settingComponent != null) { settingComponent.SetString(_bindingOverrideSettingKey, storedData); } return new InputBindingSnapshot(overridesJson); } public void LoadBindingOverrides() { EnsureInitialized(); LoadBindingOverridesCore(); } private void LoadBindingOverridesCore() { if (_runtimeActions == null) { return; } SettingComponent settingComponent = GetSettingComponent(); if (settingComponent == null || !settingComponent.HasSetting(_bindingOverrideSettingKey)) { return; } string storedData = settingComponent.GetString(_bindingOverrideSettingKey); if (!BindingOverridePersistence.TryDeserialize(storedData, out string overridesJson)) { return; } if (string.IsNullOrEmpty(overridesJson)) { return; } _runtimeActions.LoadBindingOverridesFromJson(overridesJson); } public void ApplyBindingSnapshot(InputBindingSnapshot snapshot, bool saveAfterApply = false) { EnsureInitialized(); _runtimeActions.LoadBindingOverridesFromJson(snapshot.OverridesJson); if (saveAfterApply) { SaveBindingOverrides(); } } private void EnsureInitialized() { if (_isInitialized) { return; } _runtimeActions = _inputActionsAsset != null ? Instantiate(_inputActionsAsset) : InputModuleDefaultActions.Create(); _ownsRuntimeActions = true; CacheActionMaps(); SubscribeToActions(); _isInitialized = true; SubscribeToGlobalDeviceTracking(); if (_promptMap is InputModuleDefaultPromptMap) { _promptMap = null; } int mapCount = _actionMaps.Count; if (mapCount == 0) { Log.Warning("[InputModule] Initialized but no action maps were cached. Check that action map names match InputContextId enum values."); } else { Log.Info("[InputModule] Initialized. Source: {0}, Cached maps: {1}, Startup context: {2}", _inputActionsAsset != null ? "Serialized asset" : "Default actions", mapCount, _startupContext); } if (_loadBindingOverridesOnInit) { LoadBindingOverridesCore(); } _contextStack.Set(_startupContext); if (!_onInitCalled) { Log.Warning("[InputModule] Auto-initialized before OnInit() was called. Action maps will not be enabled until OnInit() is invoked."); } } private void CacheActionMaps() { _actionMaps.Clear(); foreach (InputActionMap map in _runtimeActions.actionMaps) { if (TryParseContextId(map.name, out InputContextId contextId)) { _actionMaps[contextId] = map; } else { Log.Warning("[InputModule] ActionMap '{0}' does not match any InputContextId and will be ignored.", map.name); } } } private void SubscribeToActions() { foreach (InputAction action in _runtimeActions) { action.started += OnActionTriggered; action.performed += OnActionTriggered; action.canceled += OnActionTriggered; } } private void UnsubscribeFromActions() { if (_runtimeActions == null) { return; } foreach (InputAction action in _runtimeActions) { action.started -= OnActionTriggered; action.performed -= OnActionTriggered; action.canceled -= OnActionTriggered; } } private void SubscribeToGlobalDeviceTracking() { InputSystem.onActionChange -= OnGlobalActionChange; InputSystem.onActionChange += OnGlobalActionChange; } private void UnsubscribeFromGlobalDeviceTracking() { InputSystem.onActionChange -= OnGlobalActionChange; } private void OnGlobalActionChange(object obj, InputActionChange change) { if (change == InputActionChange.ActionPerformed && obj is InputAction.CallbackContext context) { UpdateDeviceKind(context); } } // Disables all action maps, then enables Global + the topmost context in the stack. // Only called from OnInit() (explicit activation) and OnEnable() (re-enable after disable). // The stack remembers context history for Pop restoration; only the current (topmost) // context is active at any time, preventing overlapping bindings from stacked maps. private void ApplyContextState() { if (_runtimeActions == null) { return; } _runtimeActions.Disable(); if (_actionMaps.TryGetValue(InputContextId.Global, out InputActionMap globalMap)) { globalMap.Enable(); } InputContextId current = _contextStack.Current; if (current != InputContextId.None && _actionMaps.TryGetValue(current, out InputActionMap currentMap)) { currentMap.Enable(); } } private void NotifyContextChanged() { ContextChanged?.Invoke(CurrentContext); } private void OnActionTriggered(InputAction.CallbackContext context) { if (_activeRebindOperation != null) { return; } InputActionId actionId = ParseActionId(context); InputContextId contextId = ParseContextId(context); InputDeviceKind deviceKind = UpdateDeviceKind(context); InputCommand command = CreateCommand(actionId, contextId, deviceKind, context); CommandTriggered?.Invoke(command); if (_listeners.TryGetValue(actionId, out Action listener)) { listener?.Invoke(command); } } private InputDeviceKind UpdateDeviceKind(InputAction.CallbackContext context) { InputDeviceKind deviceKind = InputDeviceKindUtility.FromDevice(context.control?.device); if (deviceKind != InputDeviceKind.Unknown && deviceKind != CurrentDeviceKind) { InputDeviceKind previous = CurrentDeviceKind; CurrentDeviceKind = deviceKind; Log.Debug("[InputModule] Device changed: {0} -> {1}", previous, deviceKind); DeviceKindChanged?.Invoke(CurrentDeviceKind); } return CurrentDeviceKind; } private static InputCommand CreateCommand( InputActionId actionId, InputContextId contextId, InputDeviceKind deviceKind, InputAction.CallbackContext context) { Vector2 vector2Value = Vector2.zero; float scalarValue = 0f; if (context.action.expectedControlType == "Vector2") { vector2Value = context.ReadValue(); scalarValue = vector2Value.magnitude; } else { scalarValue = context.ReadValue(); } return new InputCommand( actionId, contextId, ConvertPhase(context.phase), deviceKind, vector2Value, scalarValue, context.time); } private SettingComponent GetSettingComponent() { if (_settingComponent == null) { _settingComponent = UnityGameFramework.Runtime.GameEntry.GetComponent(); } return _settingComponent; } private static InputActionId ParseActionId(InputAction.CallbackContext context) { return Enum.TryParse(context.action.name, out InputActionId actionId) ? actionId : InputActionId.Unknown; } private static InputContextId ParseContextId(InputAction.CallbackContext context) { return TryParseContextId(context.action.actionMap.name, out InputContextId contextId) ? contextId : InputContextId.None; } private static bool TryParseContextId(string rawContextId, out InputContextId contextId) { return Enum.TryParse(rawContextId, out contextId); } private static InputCommandPhase ConvertPhase(InputActionPhase phase) { switch (phase) { case InputActionPhase.Started: return InputCommandPhase.Started; case InputActionPhase.Canceled: return InputCommandPhase.Canceled; default: return InputCommandPhase.Performed; } } private bool TryGetAction(InputContextId contextId, InputActionId actionId, out InputAction action) { action = null; if (!_actionMaps.TryGetValue(contextId, out InputActionMap map)) { return false; } action = map.FindAction(actionId.ToString()); return action != null; } private bool DetectConflict(InputAction action, int bindingIndex, string newPath) { if (string.IsNullOrEmpty(newPath)) { return false; } InputActionMap targetMap = action.actionMap; InputBinding targetBinding = action.bindings[bindingIndex]; foreach (InputActionMap map in _runtimeActions.actionMaps) { if (map != targetMap && map.name != nameof(InputContextId.Global)) { continue; } foreach (InputBinding binding in map.bindings) { if (map == targetMap && binding.id == targetBinding.id) { continue; } if (string.Equals(binding.effectivePath, newPath, System.StringComparison.OrdinalIgnoreCase)) { return true; } } } return false; } private InputActionRebindingExtensions.RebindingOperation CreateRebindOperation( InputAction action, InputContextId contextId, InputActionId actionId, int bindingIndex, float timeoutSeconds, InputDeviceKind expectedDeviceKind, string previousEffectivePath, string previousOverridePath) { InputActionRebindingExtensions.RebindingOperation operation = new InputActionRebindingExtensions.RebindingOperation() .WithAction(action) .WithTargetBinding(bindingIndex) .WithTimeout(timeoutSeconds) .WithControlsExcluding("/delta") .WithControlsExcluding("/position") .WithControlsExcluding("/touch*/position") .WithControlsExcluding("/touch*/delta") .WithControlsExcluding("/clickCount") .WithMatchingEventsBeingSuppressed() .OnCancel(_ => { RebindStatus status = _rebindCanceledByUser ? RebindStatus.Canceled : RebindStatus.Timeout; CompleteRebind(new RebindResult(status, contextId, actionId, bindingIndex, action.bindings[bindingIndex].effectivePath, previousEffectivePath)); }) .OnComplete(rebindOperation => { InputDeviceKind actualDeviceKind = InputDeviceKindUtility.FromDevice(rebindOperation.selectedControl?.device); if (expectedDeviceKind != InputDeviceKind.Unknown && actualDeviceKind != expectedDeviceKind) { RevertBinding(action, bindingIndex, previousOverridePath); CompleteRebind(new RebindResult(RebindStatus.Canceled, contextId, actionId, bindingIndex, action.bindings[bindingIndex].effectivePath, previousEffectivePath)); return; } string newPath = action.bindings[bindingIndex].effectivePath; if (DetectConflict(action, bindingIndex, newPath)) { RevertBinding(action, bindingIndex, previousOverridePath); CompleteRebind(new RebindResult(RebindStatus.ConflictDetected, contextId, actionId, bindingIndex, newPath, previousEffectivePath)); return; } CompleteRebind(new RebindResult(RebindStatus.Succeeded, contextId, actionId, bindingIndex, newPath, previousEffectivePath)); }); if (operation.expectedControlType != "Button") { operation.WithCancelingThrough("/escape"); } return operation; } private void CompleteRebind(RebindResult result) { _activeRebindOperation?.Dispose(); _activeRebindOperation = null; if (_contextStack.Current == InputContextId.Rebind) { PopContext(); } RebindCompleted?.Invoke(result); } private static void RevertBinding(InputAction action, int bindingIndex, string previousOverridePath) { if (string.IsNullOrEmpty(previousOverridePath)) { action.RemoveBindingOverride(bindingIndex); } else { action.ApplyBindingOverride(bindingIndex, previousOverridePath); } } private static void DisableActionForRebind(InputAction action) { InputActionMap map = action.actionMap; if (map == null) return; if (map.enabled) { map.Disable(); return; } // Map is already disabled but action.enabled may still report true // because map.Disable() bails early when m_Enabled is already false. // Force-sync by briefly enabling and re-disabling the map. if (action.enabled) { map.Enable(); map.Disable(); } } } }