(Feat) Add Remmaping
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ashwild.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// One rebindable entry: the action it targets, the binding index within that action, and the
|
||||
/// human label shown in the row. Composite parts (Move up/down/left/right) are four separate
|
||||
/// entries, each pointing at its own part-binding index.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct RebindableEntry
|
||||
{
|
||||
[SerializeField] private string actionName;
|
||||
[SerializeField] private int bindingIndex;
|
||||
[SerializeField] private string displayLabel;
|
||||
|
||||
public string ActionName => actionName;
|
||||
public int BindingIndex => bindingIndex;
|
||||
public string DisplayLabel => displayLabel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One glyph: the layout-aware display name it matches (case-insensitive) and its sprite.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct KeyIcon
|
||||
{
|
||||
[SerializeField] private string displayName;
|
||||
[SerializeField] private Sprite icon;
|
||||
|
||||
public string DisplayName => displayName;
|
||||
public Sprite Icon => icon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One authored binding override: which binding of which action to re-path, and to what control
|
||||
/// path. Used only for deliberate letter-mnemonic exceptions per layout.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct LayoutOverride
|
||||
{
|
||||
[SerializeField] private string actionName;
|
||||
[SerializeField] private int bindingIndex;
|
||||
[SerializeField] private string overridePath;
|
||||
|
||||
public string ActionName => actionName;
|
||||
public int BindingIndex => bindingIndex;
|
||||
public string OverridePath => overridePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single authored asset for everything the input UI needs: which bindings are rebindable (and
|
||||
/// their labels), the key glyphs to show instead of text, and the optional first-launch layout
|
||||
/// overrides per keyboard layout. One asset keeps all input configuration in one place — the
|
||||
/// settings panel reads the bindings/icons, the SettingsManager reads the layout overrides.
|
||||
/// Look is intentionally absent (mouse-only) and gamepad bindings are absent (keyboard only).
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "InputConfig", menuName = "Input/Input Config")]
|
||||
public class InputConfig : ScriptableObject
|
||||
{
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("Rebindable bindings (shown in the panel, in order)")]
|
||||
[SerializeField] private List<RebindableEntry> bindings = new List<RebindableEntry>();
|
||||
|
||||
[Header("Key glyphs (display name → sprite; missing falls back to text)")]
|
||||
[SerializeField] private List<KeyIcon> icons = new List<KeyIcon>();
|
||||
|
||||
[Header("First-launch layout overrides (usually empty — physical paths already fit)")]
|
||||
[SerializeField] private List<LayoutOverride> qwertyOverrides = new List<LayoutOverride>();
|
||||
[SerializeField] private List<LayoutOverride> azertyOverrides = new List<LayoutOverride>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
public IReadOnlyList<RebindableEntry> Bindings => bindings;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the glyph for a display name, or null when none is authored (caller shows text).
|
||||
/// </summary>
|
||||
public Sprite GetIcon(string displayName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(displayName)) return null;
|
||||
|
||||
foreach (KeyIcon entry in icons)
|
||||
if (string.Equals(entry.DisplayName, displayName, System.StringComparison.OrdinalIgnoreCase))
|
||||
return entry.Icon;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first-launch overrides authored for a detected layout (usually empty).
|
||||
/// </summary>
|
||||
public IReadOnlyList<LayoutOverride> OverridesFor(KeyboardLayout layout) =>
|
||||
layout == KeyboardLayout.Azerty ? azertyOverrides : qwertyOverrides;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 418d0aaab949b0b4b9a44aefc6c6ea07
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.Controls;
|
||||
|
||||
namespace Ashwild.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// The physical keyboard layouts we tell apart at first launch. Unknown means no keyboard was
|
||||
/// present or probing was inconclusive — callers treat it like QWERTY (apply no profile).
|
||||
/// </summary>
|
||||
public enum KeyboardLayout
|
||||
{
|
||||
Unknown,
|
||||
Qwerty,
|
||||
Azerty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the current OS keyboard layout by probing <see cref="Keyboard.current"/>. The
|
||||
/// primary signal is the layout-aware display name of the physical Q/W keys (AZERTY labels them
|
||||
/// A/Z); the OS-reported layout string is a fallback. Pure and side-effect-free — the caller
|
||||
/// decides what to do with the result.
|
||||
/// </summary>
|
||||
public static class KeyboardLayoutDetector
|
||||
{
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Returns the detected layout, or <see cref="KeyboardLayout.Unknown"/> when no keyboard is
|
||||
/// connected or the probes disagree with nothing conclusive.
|
||||
/// </summary>
|
||||
public static KeyboardLayout Detect()
|
||||
{
|
||||
Keyboard keyboard = Keyboard.current;
|
||||
if (keyboard == null) return KeyboardLayout.Unknown;
|
||||
|
||||
string q = Label(keyboard[Key.Q]);
|
||||
string w = Label(keyboard[Key.W]);
|
||||
|
||||
if (q == "a" || w == "z") return KeyboardLayout.Azerty;
|
||||
if (q == "q" || w == "w") return KeyboardLayout.Qwerty;
|
||||
|
||||
return FromLayoutString(keyboard.keyboardLayout);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// The lower-cased, trimmed display name of a key control, or empty when unavailable.
|
||||
/// </summary>
|
||||
private static string Label(KeyControl key)
|
||||
{
|
||||
if (key == null) return string.Empty;
|
||||
string display = key.displayName;
|
||||
return string.IsNullOrEmpty(display) ? string.Empty : display.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last-resort classification from the OS layout name (e.g. "French" / "AZERTY").
|
||||
/// </summary>
|
||||
private static KeyboardLayout FromLayoutString(string layout)
|
||||
{
|
||||
if (string.IsNullOrEmpty(layout)) return KeyboardLayout.Unknown;
|
||||
string l = layout.ToLowerInvariant();
|
||||
if (l.Contains("azerty") || l.Contains("french") || l.Contains("belg")) return KeyboardLayout.Azerty;
|
||||
return KeyboardLayout.Unknown;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6cd8469d76a2ff646885bb09cdb8a635
|
||||
Reference in New Issue
Block a user