Files
zeling_v2/Docs/Design/25_InputRebindingUI.md
2026-05-08 11:04:00 +08:00

15 KiB
Raw Permalink Blame History

25 · 输入重映射 UI

命名空间 BaseGames.Input
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.InputInputReaderSO· BaseGames.UI · Unity Input System


目录

  1. 系统总览
  2. 重映射架构
  3. RebindPanel — UI 面板
  4. RebindActionRow — 单行绑定控件
  5. 冲突检测
  6. 按键名称显示Key-to-String
  7. 持久化存储
  8. 设备切换后自动刷新
  9. 完整实现示例
  10. 编辑器友好设计

1. 系统总览

输入重映射职责:
  ├─ RebindPanel            → 重映射面板SettingsPanel 的子页签)
  ├─ RebindActionRow        → 单条 Action 的绑定显示 + 重映射按钮
  ├─ ConflictDetector       → 检测新绑定与现有绑定是否冲突
  ├─ KeyDisplayNameResolver → 将 InputControl.path 转换为可读字符串
  └─ RebindPersistence      → 将覆盖数据保存至 PlayerPrefsJSON 格式)

设计原则:重映射仅修改 InputActionAsset 的运行时 Override不改动资产本身游戏启动时从 PlayerPrefs 读取并重新应用 Override。


2. 重映射架构

数据流

玩家点击 [重新绑定] 按钮
        │
        ▼
InputActionRebindingExtensions
  .PerformInteractiveRebinding(action, bindingIndex)
        │
        ▼
等待玩家按键(屏蔽菜单键/退出键)
        │
        ├─ 冲突检测 → 若冲突:弹出警告,允许覆盖或取消
        │
        ▼
应用 Override
InputBinding.overridePath = newPath
        │
        ▼
持久化SaveOverrides()
        │
        ▼
UI 刷新:所有 RebindActionRow 更新显示

核心 Unity API

API 用途
action.PerformInteractiveRebinding(index) 启动交互式重映射
action.ApplyBindingOverride(index, path) 直接应用 Override
action.RemoveBindingOverride(index) 移除某条 Override恢复默认
asset.RemoveAllBindingOverrides() 全部恢复默认
asset.SaveBindingOverridesAsJson() 序列化所有 Override 为 JSON
asset.LoadBindingOverridesFromJson(json) 从 JSON 恢复 Override

3. RebindPanel — UI 面板

面板结构UXML

<ui:VisualElement name="RebindPanel" class="panel">
  <ui:Label text="键位设置" class="panel-title"/>
  <ui:ScrollView name="ActionList">
    <!-- RebindActionRow × N运行时动态填充-->
  </ui:ScrollView>
  <ui:VisualElement name="ButtonRow">
    <ui:Button name="ResetAllButton" text="恢复默认"/>
    <ui:Button name="CloseButton"    text="关闭"/>
  </ui:VisualElement>
  <!-- 重映射遮罩(监听按键时显示)-->
  <ui:VisualElement name="ListeningOverlay" style="display:none">
    <ui:Label name="ListeningLabel" text="请按下新按键..."/>
    <ui:Button name="CancelRebindButton" text="取消"/>
  </ui:VisualElement>
</ui:VisualElement>

RebindPanel.cs

namespace BaseGames.Input
{
    public class RebindPanel : MonoBehaviour
    {
        [SerializeField] InputReaderSO   _inputReader;
        [SerializeField] VisualTreeAsset _rowTemplate;   // RebindActionRow.uxml

        UIDocument   _doc;
        VisualElement _listeningOverlay;
        Label         _listeningLabel;

        // 需要在面板中显示的 Action 名单(顺序即显示顺序)
        static readonly string[] ShownActions =
        {
            "Move", "Jump", "Attack", "Parry", "Dash", "Heal", "Interact", "Pause"
        };

        void Awake()
        {
            _doc = GetComponent<UIDocument>();
        }

        void OnEnable()
        {
            var root = _doc.rootVisualElement;
            _listeningOverlay = root.Q("ListeningOverlay");
            _listeningLabel   = root.Q<Label>("ListeningLabel");

            root.Q<Button>("ResetAllButton").clicked += OnResetAll;
            root.Q<Button>("CancelRebindButton").clicked += OnCancelRebind;

            BuildRows(root.Q("ActionList"));
        }

        void BuildRows(VisualElement list)
        {
            list.Clear();
            var asset = _inputReader.GetInputActionAsset();
            foreach (string actionName in ShownActions)
            {
                var action = asset.FindAction(actionName);
                if (action == null) continue;
                // 为每个 Binding键盘 + 手柄)各建一行
                for (int i = 0; i < action.bindings.Count; i++)
                {
                    if (action.bindings[i].isComposite) continue;
                    var row = new RebindActionRow(action, i, _rowTemplate);
                    row.OnRebindRequested += StartRebind;
                    list.Add(row.Root);
                }
            }
        }

        InputActionRebindingExtensions.RebindingOperation _currentOp;

        void StartRebind(InputAction action, int bindingIndex)
        {
            _listeningOverlay.style.display = DisplayStyle.Flex;
            _listeningLabel.text = $"正在重映射:{action.name}  按下新按键 / Esc 取消";

            _inputReader.DisableAllInput();

            _currentOp = action
                .PerformInteractiveRebinding(bindingIndex)
                .WithCancelingThrough("<Keyboard>/escape")
                .OnMatchWaitForAnother(0.1f)
                .OnCancel(_ => FinishRebind(false))
                .OnComplete(_ =>
                {
                    string conflict = ConflictDetector.FindConflict(action, bindingIndex,
                        _inputReader.GetInputActionAsset());
                    if (conflict != null)
                    {
                        // TODO: 弹出冲突提示对话框(见 §5暂时允许覆盖
                        Debug.LogWarning($"绑定冲突:{conflict},已覆盖");
                    }
                    FinishRebind(true);
                })
                .Start();
        }

        void FinishRebind(bool save)
        {
            _currentOp?.Dispose();
            _currentOp = null;
            _listeningOverlay.style.display = DisplayStyle.None;
            _inputReader.EnableGameplayInput();
            if (save) RebindPersistence.Save(_inputReader.GetInputActionAsset());
            // 刷新所有行
            var root = _doc.rootVisualElement;
            BuildRows(root.Q("ActionList"));
        }

        void OnResetAll()
        {
            _inputReader.GetInputActionAsset().RemoveAllBindingOverrides();
            RebindPersistence.Clear();
            BuildRows(_doc.rootVisualElement.Q("ActionList"));
        }

        void OnCancelRebind() => _currentOp?.Cancel();
    }
}

4. RebindActionRow — 单行绑定控件

namespace BaseGames.Input
{
    public class RebindActionRow
    {
        public VisualElement Root { get; }
        public event System.Action<InputAction, int> OnRebindRequested;

        readonly InputAction _action;
        readonly int         _bindingIndex;
        readonly Label       _actionNameLabel;
        readonly Label       _currentBindingLabel;
        readonly Button      _rebindButton;

        public RebindActionRow(InputAction action, int bindingIndex, VisualTreeAsset template)
        {
            _action       = action;
            _bindingIndex = bindingIndex;
            Root          = template.CloneTree();

            _actionNameLabel    = Root.Q<Label>("ActionName");
            _currentBindingLabel = Root.Q<Label>("CurrentBinding");
            _rebindButton       = Root.Q<Button>("RebindButton");

            _actionNameLabel.text = action.name;
            Refresh();

            _rebindButton.clicked += () => OnRebindRequested?.Invoke(_action, _bindingIndex);
        }

        public void Refresh()
        {
            _currentBindingLabel.text = KeyDisplayNameResolver.Resolve(
                _action.bindings[_bindingIndex]);
        }
    }
}

RebindActionRow.uxml

<ui:VisualElement class="rebind-row">
  <ui:Label  name="ActionName"     class="action-name"/>
  <ui:Label  name="CurrentBinding" class="binding-label"/>
  <ui:Button name="RebindButton"   text="重新绑定" class="rebind-btn"/>
</ui:VisualElement>

5. 冲突检测

当新绑定路径已被其他 Action 使用时,触发冲突警告:

namespace BaseGames.Input
{
    public static class ConflictDetector
    {
        /// <returns>冲突的 Action 名称,无冲突返回 null</returns>
        public static string FindConflict(
            InputAction editingAction,
            int         editingBindingIndex,
            InputActionAsset asset)
        {
            string newPath = editingAction.bindings[editingBindingIndex].effectivePath;
            if (string.IsNullOrEmpty(newPath)) return null;

            foreach (var map in asset.actionMaps)
            foreach (var otherAction in map.actions)
            {
                if (otherAction == editingAction) continue;
                foreach (var binding in otherAction.bindings)
                {
                    if (binding.effectivePath == newPath)
                        return otherAction.name;
                }
            }
            return null;
        }
    }
}

冲突 UI 处理策略

场景 处理方式
绑定与同 ActionMap 的其他 Action 冲突 弹出提示:「{冲突Action} 将失去该按键绑定,是否继续?」
绑定与不同 ActionMap 冲突(如 UI Map 忽略,不同 Map 间不冲突
重复按下同一按键(与自身绑定相同) 忽略,等价于不变

6. 按键名称显示Key-to-String

InputBinding.effectivePath(如 <Keyboard>/space)转换为玩家友好字符串:

namespace BaseGames.Input
{
    public static class KeyDisplayNameResolver
    {
        static readonly Dictionary<string, string> _overrides = new()
        {
            { "<Keyboard>/leftShift",   "LShift" },
            { "<Keyboard>/rightShift",  "RShift" },
            { "<Keyboard>/leftCtrl",    "LCtrl" },
            { "<Keyboard>/space",       "空格" },
            { "<Keyboard>/escape",      "Esc" },
            { "<Keyboard>/backspace",   "退格" },
            { "<Keyboard>/enter",       "回车" },
            { "<Gamepad>/buttonSouth",  "× / A" },
            { "<Gamepad>/buttonNorth",  "△ / Y" },
            { "<Gamepad>/buttonEast",   "○ / B" },
            { "<Gamepad>/buttonWest",   "□ / X" },
            { "<Gamepad>/leftShoulder", "L1 / LB" },
            { "<Gamepad>/rightShoulder","R1 / RB" },
            { "<Gamepad>/leftTrigger",  "L2 / LT" },
            { "<Gamepad>/rightTrigger", "R2 / RT" },
            { "<Gamepad>/start",        "Start" },
        };

        public static string Resolve(InputBinding binding)
        {
            string path = binding.effectivePath;
            if (string.IsNullOrEmpty(path)) return "未绑定";
            if (_overrides.TryGetValue(path, out string display)) return display;

            // 默认:取 path 最后一段并首字母大写
            int slash = path.LastIndexOf('/');
            string key = slash >= 0 ? path[(slash + 1)..] : path;
            return char.ToUpper(key[0]) + key[1..];
        }
    }
}

7. 持久化存储

namespace BaseGames.Input
{
    public static class RebindPersistence
    {
        const string PrefsKey = "InputBindingOverrides";

        public static void Save(InputActionAsset asset)
        {
            string json = asset.SaveBindingOverridesAsJson();
            PlayerPrefs.SetString(PrefsKey, json);
            PlayerPrefs.Save();
        }

        public static void Load(InputActionAsset asset)
        {
            if (!PlayerPrefs.HasKey(PrefsKey)) return;
            string json = PlayerPrefs.GetString(PrefsKey);
            if (!string.IsNullOrEmpty(json))
                asset.LoadBindingOverridesFromJson(json);
        }

        public static void Clear()
        {
            PlayerPrefs.DeleteKey(PrefsKey);
        }
    }
}

加载时机

InputReaderSO.OnEnable() 中调用 RebindPersistence.Load(inputActionAsset),确保进入游戏前绑定已恢复:

// InputReaderSO.cs
void OnEnable()
{
    if (_inputActions == null) _inputActions = new PlayerInputActions();
    RebindPersistence.Load(_inputActions.asset);
    _inputActions.Enable();
    // ... 注册回调
}

8. 设备切换后自动刷新

当玩家切换设备(键盘 ↔ 手柄UI 面板中的按键图标/文字需要更新:

// RebindPanel.OnEnable() 中订阅
InputSystem.onActionChange += OnActionChange;
InputSystem.onDeviceChange += OnDeviceChange;

void OnDeviceChange(InputDevice device, InputDeviceChange change)
{
    if (change == InputDeviceChange.Added ||
        change == InputDeviceChange.Removed)
    {
        // 重新构建行(自动根据当前活跃设备过滤绑定)
        BuildRows(_doc.rootVisualElement.Q("ActionList"));
    }
}

9. 完整实现示例

初始化序列

游戏启动
  │
  ▼
InputReaderSO.OnEnable()
  └─ RebindPersistence.Load(asset)    // 从 PlayerPrefs 恢复 Override
  │
  ▼
玩家打开设置 → SettingsPanel.ShowTab("键位")
  └─ RebindPanel.OnEnable()
       └─ BuildRows()                 // 遍历 ShownActions 构建 RebindActionRow
  │
  ▼
玩家点击 [重新绑定]
  └─ StartRebind(action, bindingIndex)
       └─ PerformInteractiveRebinding()
  │
  ▼
玩家按下新按键
  └─ ConflictDetector.FindConflict()  // 检查冲突
  └─ FinishRebind(true)
       ├─ RebindPersistence.Save()
       └─ BuildRows()                 // 刷新 UI

10. 编辑器友好设计

RebindPanel 自定义 Inspector

[CustomEditor(typeof(RebindPanel))]
public class RebindPanelEditor : Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        var root = new VisualElement();
        InspectorElement.FillDefaultInspector(root, serializedObject, this);

        var testBtn = new Button(() =>
        {
            if (Application.isPlaying)
                Debug.Log("当前绑定 JSON:\n" +
                    ((RebindPanel)target)
                    // 通过反射或公开方法访问 asset
                    .GetComponent<UIDocument>() != null
                    ? "[请在 Play Mode 下查看]"
                    : "未运行");
        }) { text = "输出当前绑定 JSONPlay Mode" };
        root.Add(testBtn);
        return root;
    }
}

快速验证清单

验证项 方法
Override 是否持久化 重映射后重启游戏,检查按键是否保留
冲突检测是否触发 将两个 Action 绑定到同一键,检查警告弹窗
恢复默认是否生效 点击「恢复默认」后所有绑定回到 InputActions 资产原始值
设备切换刷新 拔插手柄,检查 UI 文字是否更新