15 KiB
15 KiB
25 · 输入重映射 UI
命名空间
BaseGames.Input
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Input(InputReaderSO)·BaseGames.UI· Unity Input System
目录
- 系统总览
- 重映射架构
- RebindPanel — UI 面板
- RebindActionRow — 单行绑定控件
- 冲突检测
- 按键名称显示(Key-to-String)
- 持久化存储
- 设备切换后自动刷新
- 完整实现示例
- 编辑器友好设计
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
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 = "输出当前绑定 JSON(Play Mode)" };
root.Add(testBtn);
return root;
}
}
快速验证清单
| 验证项 | 方法 |
|---|---|
| Override 是否持久化 | 重映射后重启游戏,检查按键是否保留 |
| 冲突检测是否触发 | 将两个 Action 绑定到同一键,检查警告弹窗 |
| 恢复默认是否生效 | 点击「恢复默认」后所有绑定回到 InputActions 资产原始值 |
| 设备切换刷新 | 拔插手柄,检查 UI 文字是否更新 |