chore: initial commit
This commit is contained in:
492
Docs/Design/25_InputRebindingUI.md
Normal file
492
Docs/Design/25_InputRebindingUI.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# 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 → 将覆盖数据保存至 PlayerPrefs(JSON 格式)
|
||||
```
|
||||
|
||||
**设计原则**:重映射仅修改 `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 = "输出当前绑定 JSON(Play Mode)" };
|
||||
root.Add(testBtn);
|
||||
return root;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 快速验证清单
|
||||
|
||||
| 验证项 | 方法 |
|
||||
|--------|------|
|
||||
| Override 是否持久化 | 重映射后重启游戏,检查按键是否保留 |
|
||||
| 冲突检测是否触发 | 将两个 Action 绑定到同一键,检查警告弹窗 |
|
||||
| 恢复默认是否生效 | 点击「恢复默认」后所有绑定回到 InputActions 资产原始值 |
|
||||
| 设备切换刷新 | 拔插手柄,检查 UI 文字是否更新 |
|
||||
Reference in New Issue
Block a user