using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; namespace Ironspore { public sealed class BindingSlot { public readonly InputAction Action; public readonly int Index; public readonly string LabelKey; public BindingSlot(InputAction action, int index) { Action = action; Index = index; LabelKey = "settings.action." + action.name.ToLowerInvariant() + (action.bindings[index].isPartOfComposite ? "." + action.bindings[index].name.ToLowerInvariant() : ""); } } public enum BindingChange { Success, Cancelled, Conflict, Reserved, Unsupported } public sealed class InputBindingService : IDisposable { readonly InputActionAsset input; readonly List slots = new List(); readonly List enabledActions = new List(); InputActionRebindingExtensions.RebindingOperation operation; BindingSlot activeSlot; Action onCompleted; public IReadOnlyList Slots => slots; public bool IsRebinding => operation != null; public int LastRebindFrame { get; private set; } = -1; // Input callbacks can run before Unity advances frameCount for Update. public bool SuppressMenuInput => IsRebinding || (LastRebindFrame >= 0 && Time.frameCount <= LastRebindFrame + 1); public InputBindingService(InputActionAsset input) { this.input = input; foreach (var action in input.FindActionMap("Pilot", true).actions) for (int i = 0; i < action.bindings.Count; i++) if (action.name != "Look" && !action.bindings[i].isComposite) slots.Add(new BindingSlot(action, i)); } public string Save() => input.SaveBindingOverridesAsJson(); public bool TryLoad(string json) { Cancel(); string previous = Save(); try { input.RemoveAllBindingOverrides(); if (!string.IsNullOrEmpty(json)) input.LoadBindingOverridesFromJson(json); // Older profiles may already use R. Preserve their binding and move Reload. var reload = slots.Find(slot => slot.Action.name == "Reload"); if (reload != null && !reload.Action.bindings[reload.Index].hasOverrides && Check(reload, reload.Action.bindings[reload.Index].path, out _) == BindingChange.Conflict) { foreach (char letter in "tyuiohjklbnmpxczaqwsdefgvr") if (Rebind(reload, "/" + letter, out _) == BindingChange.Success) break; } foreach (var map in input.actionMaps) foreach (var action in map.actions) foreach (var binding in action.bindings) if (binding.hasOverrides && (action.name == "Look" || binding.isComposite || binding.overrideInteractions != null || binding.overrideProcessors != null)) throw new ArgumentException("Only button binding paths can be overridden."); foreach (var slot in slots) if (slot.Action.bindings[slot.Index].hasOverrides && Check(slot, slot.Action.bindings[slot.Index].effectivePath, out _) != BindingChange.Success) throw new ArgumentException("Invalid or conflicting binding override."); return true; } catch (Exception) { input.RemoveAllBindingOverrides(); input.LoadBindingOverridesFromJson(previous); return false; } } public void Reset() { Cancel(); input.RemoveAllBindingOverrides(); } public BindingChange Check(BindingSlot target, string path, out BindingSlot conflict) { conflict = null; if (string.Equals(path, "/escape", StringComparison.OrdinalIgnoreCase) && target.Action.name != "Pause") return BindingChange.Reserved; if (!Supported(path)) return BindingChange.Unsupported; foreach (var slot in slots) if ((slot.Action != target.Action || slot.Index != target.Index) && string.Equals(slot.Action.bindings[slot.Index].effectivePath, path, StringComparison.OrdinalIgnoreCase)) { conflict = slot; return BindingChange.Conflict; } return BindingChange.Success; } public BindingChange Rebind(BindingSlot slot, string path, out BindingSlot conflict) { var result = Check(slot, path, out conflict); if (result == BindingChange.Success) slot.Action.ApplyBindingOverride(slot.Index, path); return result; } public void Begin(BindingSlot slot, Action completed) { Cancel(); activeSlot = slot; onCompleted = completed; enabledActions.Clear(); foreach (var map in input.actionMaps) foreach (var action in map.actions) if (action.enabled) enabledActions.Add(action); input.Disable(); BindingChange result = BindingChange.Cancelled; BindingSlot conflict = null; operation = slot.Action.PerformInteractiveRebinding(slot.Index) .WithExpectedControlType("Button") .WithControlsHavingToMatchPath("") .WithControlsHavingToMatchPath("") .WithControlsExcluding("/position") .WithControlsExcluding("/delta") .WithControlsExcluding("/scroll") // Let the UI adapter distinguish a cancel-button click from a left-button binding. .WithControlsExcluding("/leftButton") .WithControlsExcluding("/anyKey") .WithControlsExcluding("/shift") .WithControlsExcluding("/ctrl") .WithControlsExcluding("/alt") .WithCancelingThrough("/escape") .OnMatchWaitForAnother(0) .OnApplyBinding((_, path) => result = Rebind(slot, path, out conflict)) .OnCancel(_ => Finish(BindingChange.Cancelled, null)) .OnComplete(_ => Finish(result, conflict)); operation.Start(); } // UI Toolkit also delivers keyboard/pointer events when a platform does not forward // synthetic or accessibility input as raw device events. Both routes use the same validator. public bool CapturePath(string path) { if (!IsRebinding) return false; var completed = onCompleted; onCompleted = null; var result = Rebind(activeSlot, path, out var conflict); Cancel(); completed?.Invoke(result, conflict); return true; } void Finish(BindingChange result, BindingSlot conflict) { var finished = operation; var completed = onCompleted; operation = null; onCompleted = null; activeSlot = null; finished?.Dispose(); foreach (var action in enabledActions) action.Enable(); enabledActions.Clear(); LastRebindFrame = Time.frameCount; completed?.Invoke(result, conflict); } public void Cancel() => operation?.Cancel(); public void Dispose() => Cancel(); static bool Supported(string path) { if (string.IsNullOrEmpty(path)) return false; if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) { string name = path.Substring(11); foreach (var keyControl in InputSystem.LoadLayout("Keyboard").controls) if (keyControl.layout.ToString() == "Key" && string.Equals(keyControl.name.ToString(), name, StringComparison.OrdinalIgnoreCase)) return true; return false; } if (!path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) return false; string control = path.Substring(8).ToLowerInvariant(); return control == "leftbutton" || control == "rightbutton" || control == "middlebutton" || control == "forwardbutton" || control == "backbutton"; } public string Display(BindingSlot slot) { string path = slot.Action.bindings[slot.Index].effectivePath; string special = path?.ToLowerInvariant(); switch (special) { case "/leftbutton": return GameLocalization.Text("settings.key.mouseLeft"); case "/rightbutton": return GameLocalization.Text("settings.key.mouseRight"); case "/middlebutton": return GameLocalization.Text("settings.key.mouseMiddle"); case "/forwardbutton": return GameLocalization.Text("settings.key.mouseForward"); case "/backbutton": return GameLocalization.Text("settings.key.mouseBack"); case "/space": return GameLocalization.Text("settings.key.space"); case "/leftshift": return GameLocalization.Text("settings.key.leftShift"); case "/rightshift": return GameLocalization.Text("settings.key.rightShift"); } return slot.Action.GetBindingDisplayString(slot.Index); } public string Display(string actionName) { var names = new List(); foreach (var slot in slots) if (slot.Action.name == actionName) names.Add(Display(slot)); return string.Join(" / ", names); } } }