(Feat) Add Remmaping

This commit is contained in:
2026-06-26 16:23:20 +02:00
parent 931bc9c9e2
commit 7bd2aa3120
249 changed files with 23712 additions and 6898 deletions
+289 -2
View File
@@ -9,8 +9,11 @@ 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 via its PersistentRoot
/// parent, so it works in every scene. Replaces the old PlayerInput component + PlayerInputRouter.
/// 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.
@@ -25,6 +28,38 @@ namespace Ashwild.Player
#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")]
@@ -40,9 +75,20 @@ namespace Ashwild.Player
/// </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>
@@ -108,6 +154,9 @@ namespace Ashwild.Player
{
if (Instance == this) Instance = null;
rebindOperation?.Dispose();
rebindOperation = null;
foreach (Action undo in teardown) undo();
teardown.Clear();
@@ -248,5 +297,243 @@ namespace Ashwild.Player
}
#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
}
}