543 lines
21 KiB
C#
543 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using Ashwild.Core;
|
|
|
|
namespace Ashwild.Player
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public class InputManager : MonoBehaviour
|
|
{
|
|
#region Constants
|
|
|
|
private const string PlayerMapName = "Player";
|
|
private const string UIMapName = "UI";
|
|
|
|
#endregion
|
|
|
|
#region Types
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public enum RebindResult
|
|
{
|
|
Completed,
|
|
Cancelled,
|
|
Conflict
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="ApplySwap"/>
|
|
/// (give the other action our old key) or <see cref="RevertRebind"/> (undo ours).
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Global access point (rarely needed — consumers read PlayerEvents, not this).
|
|
/// </summary>
|
|
public static InputManager Instance { get; private set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public InputActionAsset Actions => actions;
|
|
|
|
private InputActionMap playerMap;
|
|
private InputActionMap uiMap;
|
|
|
|
/// <summary>
|
|
/// The interactive rebind in flight, kept so a new rebind or a panel close can cancel it.
|
|
/// </summary>
|
|
private InputActionRebindingExtensions.RebindingOperation rebindOperation;
|
|
|
|
/// <summary>
|
|
/// Undo callbacks recorded while wiring actions, invoked on teardown.
|
|
/// </summary>
|
|
private readonly List<Action> teardown = new List<Action>();
|
|
|
|
#endregion
|
|
|
|
#region Unity Lifecycle
|
|
|
|
/// <summary>
|
|
/// Establishes the persistent singleton and wires every action to the bus.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes to the session lifecycle that gates the gameplay map.
|
|
/// </summary>
|
|
private void OnEnable()
|
|
{
|
|
PlayerEvents.LocalPlayerSpawned += EnableGameplay;
|
|
PlayerEvents.SessionStopped += DisableGameplay;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unsubscribes — mirrors OnEnable.
|
|
/// </summary>
|
|
private void OnDisable()
|
|
{
|
|
PlayerEvents.LocalPlayerSpawned -= EnableGameplay;
|
|
PlayerEvents.SessionStopped -= DisableGameplay;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tears down all action subscriptions and disables the maps.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Enables gameplay input once the local player exists.
|
|
/// </summary>
|
|
private void EnableGameplay() => playerMap?.Enable();
|
|
|
|
/// <summary>
|
|
/// Disables gameplay input when the session ends (back to the menu).
|
|
/// </summary>
|
|
private void DisableGameplay() => playerMap?.Disable();
|
|
|
|
#endregion
|
|
|
|
#region Wiring
|
|
|
|
/// <summary>
|
|
/// Maps every gameplay action to its PlayerEvents raiser.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps the UI Cancel action to the bus.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Button press → parameterless raise.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hold action → true on press, false on release.
|
|
/// </summary>
|
|
private void WireHold(InputActionMap map, string name, Action<bool> 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; });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vector2 value → current value on change, zero on release.
|
|
/// </summary>
|
|
private void WireVector2(InputActionMap map, string name, Action<Vector2> raise)
|
|
{
|
|
InputAction action = Resolve(map, name);
|
|
if (action == null) return;
|
|
|
|
void Value(InputAction.CallbackContext c) => raise(c.ReadValue<Vector2>());
|
|
void Reset(InputAction.CallbackContext _) => raise(Vector2.zero);
|
|
action.performed += Value;
|
|
action.canceled += Reset;
|
|
teardown.Add(() => { action.performed -= Value; action.canceled -= Reset; });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scroll value → forwards its Y delta.
|
|
/// </summary>
|
|
private void WireScrollY(InputActionMap map, string name, Action<float> raise)
|
|
{
|
|
InputAction action = Resolve(map, name);
|
|
if (action == null) return;
|
|
|
|
void Value(InputAction.CallbackContext c) => raise(c.ReadValue<Vector2>().y);
|
|
action.performed += Value;
|
|
teardown.Add(() => action.performed -= Value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds an action in a map, warning (not throwing) when it is missing.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void StartInteractiveRebind(string actionName, int bindingIndex, Action<RebindOutcome> 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("<Mouse>/position")
|
|
.WithControlsExcluding("<Mouse>/delta")
|
|
.WithCancelingThrough("<Keyboard>/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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void CancelOngoingRebind() => rebindOperation?.Cancel();
|
|
|
|
/// <summary>
|
|
/// Restores one binding to its asset default by removing its override. Does not persist —
|
|
/// the caller saves through the SettingsManager.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears every binding override on the asset (back to authored defaults). Does not persist.
|
|
/// </summary>
|
|
public void ResetAllBindings() => actions.RemoveAllBindingOverrides();
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void ApplySwap(RebindOutcome outcome)
|
|
{
|
|
InputAction conflict = actions.FindAction(outcome.ConflictActionName, false);
|
|
if (conflict != null) conflict.ApplyBindingOverride(outcome.ConflictBindingIndex, outcome.PreviousPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void CompleteRebind(InputAction action, string actionName, int bindingIndex, string previousOverride, string previousPath, bool wasEnabled, Action<RebindOutcome> 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
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes the operation, restores the action's prior enabled state and reports the outcome.
|
|
/// </summary>
|
|
private void EndRebind(InputAction action, bool wasEnabled, Action<RebindOutcome> onOutcome, RebindOutcome outcome)
|
|
{
|
|
DisposeAndRestore(action, wasEnabled);
|
|
onOutcome?.Invoke(outcome);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes the rebind operation and restores the action's prior enabled state.
|
|
/// </summary>
|
|
private void DisposeAndRestore(InputAction action, bool wasEnabled)
|
|
{
|
|
rebindOperation?.Dispose();
|
|
rebindOperation = null;
|
|
if (wasEnabled) action.Enable();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public bool ApplyLayoutOverrides(IReadOnlyList<LayoutOverride> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The current binding overrides as a portable JSON blob, for the SettingsManager to persist.
|
|
/// </summary>
|
|
public string GetOverridesJson() => actions.SaveBindingOverridesAsJson();
|
|
|
|
/// <summary>
|
|
/// Applies previously-saved binding overrides (JSON) onto the shared asset.
|
|
/// </summary>
|
|
public void LoadOverridesJson(string json)
|
|
{
|
|
if (!string.IsNullOrEmpty(json)) actions.LoadBindingOverridesFromJson(json);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|