chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View 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 → 将覆盖数据保存至 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 文字是否更新 |