using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; using Ashwild.Core; namespace Ashwild.Player { /// /// The single, persistent source of input. It reads the project InputActionAsset and republishes /// every action onto the static PlayerEvents bus, so gameplay and UI both consume input the same /// way without depending on a player GameObject. Persists across scenes, so it works everywhere. /// /// It also owns the binding *mechanism* — interactive rebinds, resets and applying overrides on /// the shared asset — but never touches disk: persistence (and the layout-profile decision) lives /// in the SettingsManager, which calls this manager to apply and reads back the overrides JSON. /// /// The UI map (Cancel) is always active; the Player map is only active in-game (between /// LocalPlayerSpawned and SessionStopped) so menu typing never triggers gameplay actions. /// [DisallowMultipleComponent] public class InputManager : MonoBehaviour { #region Constants private const string PlayerMapName = "Player"; private const string UIMapName = "UI"; #endregion #region Types /// /// Result of an interactive rebind: applied cleanly, cancelled with no change, or the chosen /// control already drives another binding in the same map (a conflict the UI must resolve). /// public enum RebindResult { Completed, Cancelled, Conflict } /// /// Everything the UI needs to resolve a rebind: what was rebound (its old override/path, to /// revert or swap) and, on a conflict, which other action/binding already uses the chosen key. /// On a conflict the new binding is left applied — the UI then calls /// (give the other action our old key) or (undo ours). /// public struct RebindOutcome { public RebindResult Result; public string ActionName; public int BindingIndex; public string PreviousOverride; public string PreviousPath; public string ConflictActionName; public int ConflictBindingIndex; } #endregion #region Serialized Fields [Header("Input")] [Tooltip("The InputSystem_Actions asset (Player + UI maps).")] [SerializeField] private InputActionAsset actions; #endregion #region State /// /// Global access point (rarely needed — consumers read PlayerEvents, not this). /// public static InputManager Instance { get; private set; } /// /// The project InputActionAsset this manager reads. Exposed so the settings layer can read /// binding display strings; the manager itself only consumes it for runtime input. /// public InputActionAsset Actions => actions; private InputActionMap playerMap; private InputActionMap uiMap; /// /// The interactive rebind in flight, kept so a new rebind or a panel close can cancel it. /// private InputActionRebindingExtensions.RebindingOperation rebindOperation; /// /// Undo callbacks recorded while wiring actions, invoked on teardown. /// private readonly List teardown = new List(); #endregion #region Unity Lifecycle /// /// Establishes the persistent singleton and wires every action to the bus. /// private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; // Detach to the scene root and persist — leaves the (possibly mixed) Managers parent // behind so non-persistent siblings die with the scene as usual. Persistence.Persist(gameObject); if (actions == null) { Debug.LogError("[InputManager] No InputActionAsset assigned.", this); return; } playerMap = actions.FindActionMap(PlayerMapName, false); uiMap = actions.FindActionMap(UIMapName, false); WirePlayerMap(); WireUIMap(); // Cancel (back / pause / close) must work everywhere, menu included. uiMap?.Enable(); } /// /// Subscribes to the session lifecycle that gates the gameplay map. /// private void OnEnable() { PlayerEvents.LocalPlayerSpawned += EnableGameplay; PlayerEvents.SessionStopped += DisableGameplay; } /// /// Unsubscribes — mirrors OnEnable. /// private void OnDisable() { PlayerEvents.LocalPlayerSpawned -= EnableGameplay; PlayerEvents.SessionStopped -= DisableGameplay; } /// /// Tears down all action subscriptions and disables the maps. /// private void OnDestroy() { if (Instance == this) Instance = null; rebindOperation?.Dispose(); rebindOperation = null; foreach (Action undo in teardown) undo(); teardown.Clear(); playerMap?.Disable(); uiMap?.Disable(); } #endregion #region Gameplay Map Gating /// /// Enables gameplay input once the local player exists. /// private void EnableGameplay() => playerMap?.Enable(); /// /// Disables gameplay input when the session ends (back to the menu). /// private void DisableGameplay() => playerMap?.Disable(); #endregion #region Wiring /// /// Maps every gameplay action to its PlayerEvents raiser. /// private void WirePlayerMap() { if (playerMap == null) { Debug.LogError($"[InputManager] No '{PlayerMapName}' action map found.", this); return; } WireVector2(playerMap, "Move", PlayerEvents.RaiseMoveInput); WireVector2(playerMap, "Look", PlayerEvents.RaiseLookInput); WireButton(playerMap, "Jump", PlayerEvents.RaiseJumpPressed); WireHold(playerMap, "Sprint", PlayerEvents.RaiseSprintHeld); WireButton(playerMap, "Crouch", PlayerEvents.RaiseCrouchPressed); WireButton(playerMap, "Attack", PlayerEvents.RaiseAttackPressed); WireButton(playerMap, "SecondaryUse", PlayerEvents.RaiseSecondaryUsePressed); WireHold(playerMap, "SecondaryUse", PlayerEvents.RaiseSecondaryUseHeld); WireButton(playerMap, "Interact", PlayerEvents.RaiseInteractPressed); WireButton(playerMap, "Drop", PlayerEvents.RaiseDropPressed); WireButton(playerMap, "Inventory", PlayerEvents.RaiseInventoryTogglePressed); WireScrollY(playerMap, "HotbarScroll", PlayerEvents.RaiseHotbarScroll); WireButton(playerMap, "BuildRotate", PlayerEvents.RaiseBuildRotatePressed); for (int i = 0; i < 10; i++) { int slot = i; WireButton(playerMap, $"HotbarSlot{i + 1}", () => PlayerEvents.RaiseHotbarSlotPressed(slot)); } } /// /// Maps the UI Cancel action to the bus. /// private void WireUIMap() { if (uiMap == null) { Debug.LogError($"[InputManager] No '{UIMapName}' action map found.", this); return; } WireButton(uiMap, "Cancel", PlayerEvents.RaiseCancelPressed); } #endregion #region Wiring Helpers /// /// Button press → parameterless raise. /// private void WireButton(InputActionMap map, string name, Action raise) { InputAction action = Resolve(map, name); if (action == null) return; void Handler(InputAction.CallbackContext _) => raise(); action.performed += Handler; teardown.Add(() => action.performed -= Handler); } /// /// Hold action → true on press, false on release. /// private void WireHold(InputActionMap map, string name, Action raise) { InputAction action = Resolve(map, name); if (action == null) return; void Down(InputAction.CallbackContext _) => raise(true); void Up(InputAction.CallbackContext _) => raise(false); action.performed += Down; action.canceled += Up; teardown.Add(() => { action.performed -= Down; action.canceled -= Up; }); } /// /// Vector2 value → current value on change, zero on release. /// private void WireVector2(InputActionMap map, string name, Action raise) { InputAction action = Resolve(map, name); if (action == null) return; void Value(InputAction.CallbackContext c) => raise(c.ReadValue()); void Reset(InputAction.CallbackContext _) => raise(Vector2.zero); action.performed += Value; action.canceled += Reset; teardown.Add(() => { action.performed -= Value; action.canceled -= Reset; }); } /// /// Scroll value → forwards its Y delta. /// private void WireScrollY(InputActionMap map, string name, Action raise) { InputAction action = Resolve(map, name); if (action == null) return; void Value(InputAction.CallbackContext c) => raise(c.ReadValue().y); action.performed += Value; teardown.Add(() => action.performed -= Value); } /// /// Finds an action in a map, warning (not throwing) when it is missing. /// private InputAction Resolve(InputActionMap map, string name) { InputAction action = map.FindAction(name, false); if (action == null) Debug.LogWarning($"[InputManager] Action '{name}' not found in map '{map.name}'.", this); return action; } #endregion #region Rebinding API /// /// The layout-aware display string for one binding (what a keybind row shows). On AZERTY this /// returns ZQSD automatically because Unity resolves physical paths through the OS layout. /// public string GetBindingDisplayString(string actionName, int bindingIndex) { InputAction action = actions.FindAction(actionName, false); if (action == null) { Debug.LogWarning($"[InputManager] Cannot display binding — action '{actionName}' not found.", this); return string.Empty; } return action.GetBindingDisplayString(bindingIndex); } /// /// The resolved control path of one binding (e.g. "leftShift", "leftButton", "w") — a stable, /// layout-independent key the UI uses to find a glyph when the display string has no icon. /// public string GetBindingControlPath(string actionName, int bindingIndex) { InputAction action = actions.FindAction(actionName, false); if (action == null) return string.Empty; action.GetBindingDisplayString(bindingIndex, out _, out string controlPath); return controlPath ?? string.Empty; } /// /// Starts an interactive rebind for one binding. Disables the action for the duration /// (required by the Input System), rejects a control already bound elsewhere in the same map /// (duplicate), and reports the outcome via the callback. Does not persist — the caller /// (settings panel) saves through the SettingsManager. The action's prior enabled state is /// restored so a menu rebind never enables gameplay input. /// public void StartInteractiveRebind(string actionName, int bindingIndex, Action onOutcome) { InputAction action = actions.FindAction(actionName, false); if (action == null) { Debug.LogError($"[InputManager] Cannot rebind — action '{actionName}' not found.", this); onOutcome?.Invoke(new RebindOutcome { Result = RebindResult.Cancelled, ActionName = actionName, BindingIndex = bindingIndex }); return; } CancelOngoingRebind(); string previousOverride = action.bindings[bindingIndex].overridePath; string previousPath = action.bindings[bindingIndex].effectivePath; bool wasEnabled = action.enabled; action.Disable(); rebindOperation = action.PerformInteractiveRebinding(bindingIndex) .WithControlsExcluding("/position") .WithControlsExcluding("/delta") .WithCancelingThrough("/escape") .OnCancel(_ => EndRebind(action, wasEnabled, onOutcome, new RebindOutcome { Result = RebindResult.Cancelled, ActionName = actionName, BindingIndex = bindingIndex, PreviousOverride = previousOverride, PreviousPath = previousPath })) .OnComplete(_ => CompleteRebind(action, actionName, bindingIndex, previousOverride, previousPath, wasEnabled, onOutcome)) .Start(); } /// /// Cancels the rebind in flight (e.g. the panel was closed mid-capture), restoring state via /// its cancel callback. No-op when nothing is running. /// public void CancelOngoingRebind() => rebindOperation?.Cancel(); /// /// Restores one binding to its asset default by removing its override. Does not persist — /// the caller saves through the SettingsManager. /// public void ResetBinding(string actionName, int bindingIndex) { InputAction action = actions.FindAction(actionName, false); if (action == null) { Debug.LogError($"[InputManager] Cannot reset — action '{actionName}' not found.", this); return; } action.RemoveBindingOverride(bindingIndex); } /// /// Clears every binding override on the asset (back to authored defaults). Does not persist. /// public void ResetAllBindings() => actions.RemoveAllBindingOverrides(); /// /// Resolves a conflict by SWAPPING the two keys: the new binding keeps the chosen key (already /// applied) and the other action receives the key this one used before. Does not persist. /// public void ApplySwap(RebindOutcome outcome) { InputAction conflict = actions.FindAction(outcome.ConflictActionName, false); if (conflict != null) conflict.ApplyBindingOverride(outcome.ConflictBindingIndex, outcome.PreviousPath); } /// /// Undoes a rebind, restoring the binding to the override it had before (used to cancel a /// conflict, leaving the other action untouched). Does not persist. /// public void RevertRebind(RebindOutcome outcome) { InputAction action = actions.FindAction(outcome.ActionName, false); if (action == null) return; if (string.IsNullOrEmpty(outcome.PreviousOverride)) action.RemoveBindingOverride(outcome.BindingIndex); else action.ApplyBindingOverride(outcome.BindingIndex, outcome.PreviousOverride); } #endregion #region Rebinding Internals /// /// Resolves a completed capture: leaves the new binding applied and reports either a clean /// completion or a conflict (with the other action that already uses the key) for the UI. /// private void CompleteRebind(InputAction action, string actionName, int bindingIndex, string previousOverride, string previousPath, bool wasEnabled, Action onOutcome) { DisposeAndRestore(action, wasEnabled); string newPath = action.bindings[bindingIndex].effectivePath; if (FindConflict(action, bindingIndex, newPath, out string conflictAction, out int conflictIndex)) { onOutcome?.Invoke(new RebindOutcome { Result = RebindResult.Conflict, ActionName = actionName, BindingIndex = bindingIndex, PreviousOverride = previousOverride, PreviousPath = previousPath, ConflictActionName = conflictAction, ConflictBindingIndex = conflictIndex }); return; } onOutcome?.Invoke(new RebindOutcome { Result = RebindResult.Completed, ActionName = actionName, BindingIndex = bindingIndex, PreviousOverride = previousOverride, PreviousPath = previousPath }); } /// /// Disposes the operation, restores the action's prior enabled state and reports the outcome. /// private void EndRebind(InputAction action, bool wasEnabled, Action onOutcome, RebindOutcome outcome) { DisposeAndRestore(action, wasEnabled); onOutcome?.Invoke(outcome); } /// /// Disposes the rebind operation and restores the action's prior enabled state. /// private void DisposeAndRestore(InputAction action, bool wasEnabled) { rebindOperation?.Dispose(); rebindOperation = null; if (wasEnabled) action.Enable(); } /// /// Finds the first other non-composite binding in the same map that resolves to the chosen /// control, returning its action name and binding index within that action. /// private bool FindConflict(InputAction action, int bindingIndex, string newPath, out string conflictActionName, out int conflictBindingIndex) { conflictActionName = null; conflictBindingIndex = -1; if (string.IsNullOrEmpty(newPath)) return false; Guid editedId = action.bindings[bindingIndex].id; foreach (InputAction other in action.actionMap.actions) { var bindings = other.bindings; for (int i = 0; i < bindings.Count; i++) { InputBinding b = bindings[i]; if (b.isComposite) continue; if (b.id == editedId) continue; if (b.effectivePath == newPath) { conflictActionName = other.name; conflictBindingIndex = i; return true; } } } return false; } #endregion #region Overrides (no disk I/O) /// /// Applies a layout's authored overrides onto the shared asset. Returns true when at least /// one override was applied, so the caller knows there is something to persist. Usually a /// no-op: physical paths already fit every layout (a hook for letter-mnemonic exceptions). /// public bool ApplyLayoutOverrides(IReadOnlyList overrides) { if (overrides == null || overrides.Count == 0) return false; foreach (LayoutOverride o in overrides) { InputAction action = actions.FindAction(o.ActionName, false); if (action == null) { Debug.LogWarning($"[InputManager] Layout profile references unknown action '{o.ActionName}'.", this); continue; } action.ApplyBindingOverride(o.BindingIndex, o.OverridePath); } return true; } /// /// The current binding overrides as a portable JSON blob, for the SettingsManager to persist. /// public string GetOverridesJson() => actions.SaveBindingOverridesAsJson(); /// /// Applies previously-saved binding overrides (JSON) onto the shared asset. /// public void LoadOverridesJson(string json) { if (!string.IsNullOrEmpty(json)) actions.LoadBindingOverridesFromJson(json); } #endregion } }