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

493 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 25 · 输入重映射 UI
> **命名空间** `BaseGames.Input`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Input`InputReaderSO· `BaseGames.UI` · Unity Input System
---
## 目录
1. [系统总览](#1-系统总览)
2. [重映射架构](#2-重映射架构)
3. [RebindPanel — UI 面板](#3-rebindpanel--ui-面板)
4. [RebindActionRow — 单行绑定控件](#4-rebindactionrow--单行绑定控件)
5. [冲突检测](#5-冲突检测)
6. [按键名称显示Key-to-String](#6-按键名称显示key-to-string)
7. [持久化存储](#7-持久化存储)
8. [设备切换后自动刷新](#8-设备切换后自动刷新)
9. [完整实现示例](#9-完整实现示例)
10. [编辑器友好设计](#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
```csharp
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 — 单行绑定控件
```csharp
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
```xml
<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 使用时,触发冲突警告:
```csharp
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`)转换为玩家友好字符串:
```csharp
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. 持久化存储
```csharp
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)`,确保进入游戏前绑定已恢复:
```csharp
// InputReaderSO.cs
void OnEnable()
{
if (_inputActions == null) _inputActions = new PlayerInputActions();
RebindPersistence.Load(_inputActions.asset);
_inputActions.Enable();
// ... 注册回调
}
```
---
## 8. 设备切换后自动刷新
当玩家切换设备(键盘 ↔ 手柄UI 面板中的按键图标/文字需要更新:
```csharp
// 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
```csharp
[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 文字是否更新 |