980 lines
32 KiB
Markdown
980 lines
32 KiB
Markdown
# InputModule 接入说明
|
||
|
||
## 设计原则
|
||
|
||
`InputModule` 是**可选功能模块**。
|
||
|
||
因此依赖方向必须始终保持为:
|
||
|
||
- `InputModule` 可以依赖当前基座项目约定(UGF / `GameEntry` / Procedure 接入方式)
|
||
- **基座项目默认代码不能反向依赖 `InputModule`**
|
||
|
||
也就是说:
|
||
|
||
- 本仓库中的基座代码默认不直接接入输入模块
|
||
- 当某个项目决定启用该模块时,再按下面步骤**显式接入**
|
||
|
||
---
|
||
|
||
## 编译归属说明
|
||
|
||
当前模块采用的是:
|
||
|
||
- `Base` 独立程序集
|
||
- `Runtime` 通过 **asmref** 并入 `SepCore.Runtime`
|
||
|
||
也就是说:
|
||
|
||
- `Assets/Plugins/InputModule/Base/` 中的代码仍然是独立模块基础层
|
||
- `Assets/Plugins/InputModule/Runtime/` 中的代码虽然命名空间仍是 `SepCore.InputModule.Runtime`,但**编译归属属于 `SepCore.Runtime`**
|
||
|
||
这样做的目的,是让 `InputModuleComponent` 能与基座里的其他 Runtime Component 保持**平级协作**,避免为了跨程序集调用再额外抽一层接口。
|
||
|
||
因此这里的“可选”含义是:
|
||
|
||
- **接入流程是可选的**
|
||
- **导入模块后是否在项目里启用它是可选的**
|
||
- 而不是要求 Runtime 代码必须物理编译成一个独立运行时程序集
|
||
|
||
---
|
||
|
||
## 目录说明
|
||
|
||
- `Assets/Plugins/InputModule/Base/`
|
||
- 独立基础层
|
||
- 提供 `InputContextId`、`InputActionId`、`InputCommand` 等基础定义
|
||
- `Assets/Plugins/InputModule/Runtime/`
|
||
- 运行时代码
|
||
- 通过 `SepCore.Runtime.InputModule.asmref` 并入 `SepCore.Runtime`
|
||
- `Assets/Tests/PlayMode/InputModule/`
|
||
- 当前模块的 PlayMode 测试
|
||
|
||
---
|
||
|
||
## 推荐接入流程
|
||
|
||
### 1. 导入模块
|
||
|
||
将 `Assets/Plugins/InputModule` 导入到目标项目。
|
||
|
||
导入后请确认:
|
||
|
||
- `Assets/Plugins/InputModule/Base/InputModule.Base.asmdef`
|
||
- `Assets/Plugins/InputModule/Runtime/SepCore.Runtime.InputModule.asmref`
|
||
|
||
都已一并导入。
|
||
|
||
其中:
|
||
|
||
- `InputModule.Base.asmdef` 提供基础定义程序集
|
||
- `SepCore.Runtime.InputModule.asmref` 负责把 Runtime 代码并入 `SepCore.Runtime`
|
||
|
||
---
|
||
|
||
### 2. 在 `GameEntry.Custom.cs` 中增加模块访问入口
|
||
|
||
文件:
|
||
|
||
- `Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs`
|
||
|
||
接入示例:
|
||
|
||
```csharp
|
||
using SepCore.InputModule.Runtime;
|
||
using SepCore.CustomComponent;
|
||
using UnityEngine;
|
||
|
||
public partial class GameEntry : MonoBehaviour
|
||
{
|
||
public static BuiltinDataComponent BuiltinData { get; private set; }
|
||
|
||
public static InputModuleComponent InputModule { get; private set; }
|
||
|
||
private static void InitCustomComponents()
|
||
{
|
||
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
|
||
InputModule = UnityGameFramework.Runtime.GameEntry.GetComponent<InputModuleComponent>();
|
||
}
|
||
}
|
||
```
|
||
|
||
说明:
|
||
|
||
- `InputModule` 访问入口是**接入方项目自己加的**
|
||
- 这一步是“启用模块”的显式行为
|
||
- 不启用模块的项目,不需要添加这段代码
|
||
- 这里虽然 `InputModuleComponent` 的命名空间是 `SepCore.InputModule.Runtime`,但它实际已经通过 asmref 编译进 `SepCore.Runtime`
|
||
|
||
---
|
||
|
||
### 3. 在 `Launcher` 场景中挂载 `InputModuleComponent`
|
||
|
||
推荐做法:
|
||
|
||
- 在 `Launcher` 场景中新建一个 GameObject
|
||
- 挂载 `SepCore.InputModule.Runtime.InputModuleComponent`
|
||
|
||
建议命名:
|
||
|
||
- `InputModule`
|
||
|
||
你也可以把它挂在已有的系统节点上,只要生命周期符合项目约定即可。
|
||
|
||
推荐理解方式:
|
||
|
||
- `InputModuleComponent` 是插件提供的 Runtime Component
|
||
- 但它不是“高一层服务”,而是与 `BuiltinDataComponent`、`UIComponent`、`SoundComponent` 等在运行时层面平级协作
|
||
|
||
---
|
||
|
||
### 4. 在游戏启动 Procedure 中显式初始化
|
||
|
||
推荐在某个启动流程里调用:
|
||
|
||
```csharp
|
||
GameEntry.InputModule.OnInit();
|
||
```
|
||
|
||
例如:
|
||
|
||
```csharp
|
||
protected override void OnEnter(IFsm<IProcedureManager> procedureOwner)
|
||
{
|
||
base.OnEnter(procedureOwner);
|
||
GameEntry.InputModule.OnInit();
|
||
}
|
||
```
|
||
|
||
说明:
|
||
|
||
- `OnInit()` 是模块正式开始工作的入口
|
||
- 这一步会初始化 action asset、缓存 action map、订阅输入回调、按配置加载重绑数据
|
||
|
||
---
|
||
|
||
### 5. 在具体业务 Procedure / UI 中切换输入上下文
|
||
|
||
示例:
|
||
|
||
```csharp
|
||
GameEntry.InputModule.SetContext(InputContextId.UI);
|
||
GameEntry.InputModule.SetContext(InputContextId.GameplayExplore);
|
||
GameEntry.InputModule.PushContext(InputContextId.Dialog);
|
||
GameEntry.InputModule.PopContext();
|
||
GameEntry.InputModule.ClearContexts();
|
||
```
|
||
|
||
---
|
||
|
||
## 最小接入示例
|
||
|
||
以下是从零到跑通 InputModule 的完整步骤,可直接复制使用。
|
||
|
||
**第一步:GameEntry.Custom.cs**
|
||
|
||
```csharp
|
||
using SepCore.InputModule.Runtime;
|
||
using SepCore.CustomComponent;
|
||
using UnityEngine;
|
||
|
||
public partial class GameEntry : MonoBehaviour
|
||
{
|
||
public static BuiltinDataComponent BuiltinData { get; private set; }
|
||
public static InputModuleComponent InputModule { get; private set; }
|
||
|
||
private static void InitCustomComponents()
|
||
{
|
||
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
|
||
InputModule = UnityGameFramework.Runtime.GameEntry.GetComponent<InputModuleComponent>();
|
||
}
|
||
}
|
||
```
|
||
|
||
**第二步:启动 Procedure 中调用 OnInit()**
|
||
|
||
```csharp
|
||
protected override void OnEnter(ProcedureOwner procedureOwner)
|
||
{
|
||
base.OnEnter(procedureOwner);
|
||
// ... 其他初始化 ...
|
||
GameEntry.InputModule.OnInit();
|
||
}
|
||
```
|
||
|
||
**第三步:Launcher 场景挂载**
|
||
|
||
在 `Game Framework > Customs` 节点下新建 GameObject `InputModule`,挂载 `InputModuleComponent`。Inspector 中 `_inputActionsAsset` 留空即使用默认生成的 action map。
|
||
|
||
**第四步:业务中切换上下文**
|
||
|
||
```csharp
|
||
// 进入游戏玩法
|
||
GameEntry.InputModule.SetGameplayExploreContext();
|
||
|
||
// 打开菜单
|
||
GameEntry.InputModule.SetUIContext();
|
||
|
||
// 关闭菜单,恢复玩法
|
||
GameEntry.InputModule.SetGameplayExploreContext();
|
||
|
||
// 打开对话(从 GameplayExplore 压栈)
|
||
GameEntry.InputModule.PushContext(InputContextId.Dialog);
|
||
|
||
// 关闭对话(弹栈恢复 GameplayExplore)
|
||
GameEntry.InputModule.PopContext();
|
||
```
|
||
|
||
---
|
||
|
||
## Launcher 场景挂载检查清单
|
||
|
||
- [ ] 在 `Game Framework > Customs` 节点下新建 GameObject,命名 `InputModule`
|
||
- [ ] 挂载 `InputModuleComponent`(`SepCore.InputModule.Runtime.InputModuleComponent`)
|
||
- [ ] `_inputActionsAsset`:如果不使用默认 action,拖入自定义 `InputActionAsset`;否则留空(自动使用代码生成的默认 action)
|
||
- [ ] `_startupContext`:推荐设为 `GameplayExplore` 或 `None`
|
||
- [ ] `_loadBindingOverridesOnInit`:一般保持 `true`
|
||
- [ ] `_saveBindingOverridesOnDestroy`:按需设置
|
||
- [ ] 检查 EventSystem:`InputSystemUIInputModule` 保持启用,作为当前阶段 UI 主路径
|
||
|
||
---
|
||
|
||
## 推荐 GameObject 命名规范
|
||
|
||
- 推荐命名:`InputModule`
|
||
- 推荐挂载位置:`Game Framework > Customs` 节点下(与 `Builtin Data` 平级)
|
||
- 不要挂在 `Builtin` 预制体内部(那是框架自带的,不应修改)
|
||
- `InputModuleComponent` 标记了 `[DisallowMultipleComponent]`,同一 GameObject 上只能挂一个
|
||
|
||
---
|
||
|
||
## 与 InputSystemUIInputModule 的职责划分
|
||
|
||
当前推荐策略是:**UI 原始输入继续交给 `InputSystemUIInputModule` / EventSystem,InputModule 只负责 gameplay 语义输入与上下文切换。**
|
||
|
||
**分工:**
|
||
|
||
- `InputSystemUIInputModule`:驱动 Unity EventSystem,处理 UI 交互(按钮点击、拖拽、导航等)
|
||
- `StandaloneInputModule`:旧版输入模块兜底(是否保留按项目兼容需求决定)
|
||
- `InputModuleComponent`:处理 gameplay / 业务语义输入(通过 `CommandTriggered` 事件和 `RegisterListener`),不直接驱动 EventSystem
|
||
|
||
**推荐策略:**
|
||
|
||
- **移动端 UI:** 普通按钮点击、拖拽、滚动等继续走 `InputSystemUIInputModule + EventSystem`
|
||
- **PC UI:** 鼠标指针点击继续走 `InputSystemUIInputModule + EventSystem`
|
||
- **主机 UI:** 当前阶段也优先沿用 EventSystem 导航能力,而不是为了统一入口额外接管 UI 输入
|
||
- **Gameplay 输入:** `Pause / Move / Interact / Sprint` 等语义继续由 `InputModule` 管理
|
||
|
||
换句话说:
|
||
|
||
- **UI 事件分发** 不作为当前阶段 InputModule 的统一目标
|
||
- **Gameplay 语义输入统一** 才是当前阶段 InputModule 的主线目标
|
||
- **UI 打开/关闭** 对 InputModule 的主要影响是“切换 gameplay context”,而不是“替代 EventSystem”
|
||
|
||
---
|
||
|
||
## 上下文切换示例
|
||
|
||
### 场景 1: 游戏探索中打开菜单
|
||
|
||
```csharp
|
||
// 打开菜单
|
||
GameEntry.InputModule.SetContext(InputContextId.UI);
|
||
|
||
// 关闭菜单,恢复探索
|
||
GameEntry.InputModule.SetGameplayExploreContext();
|
||
```
|
||
|
||
### 场景 2: 游戏探索中触发对话
|
||
|
||
```csharp
|
||
// 对话打开(GameplayExplore 被压栈,只有栈顶 Dialog 生效,GameplayExplore 暂停)
|
||
GameEntry.InputModule.PushContext(InputContextId.Dialog);
|
||
|
||
// 监听对话输入
|
||
GameEntry.InputModule.RegisterListener(InputActionId.Confirm, OnDialogConfirm);
|
||
GameEntry.InputModule.RegisterListener(InputActionId.Cancel, OnDialogCancel);
|
||
|
||
// 对话关闭(弹栈,恢复 GameplayExplore)
|
||
GameEntry.InputModule.UnregisterListener(InputActionId.Confirm, OnDialogConfirm);
|
||
GameEntry.InputModule.UnregisterListener(InputActionId.Cancel, OnDialogCancel);
|
||
GameEntry.InputModule.PopContext();
|
||
```
|
||
|
||
### 场景 3: UI 中弹出确认对话框
|
||
|
||
```csharp
|
||
// 弹出确认框(替换当前 UI 上下文)
|
||
GameEntry.InputModule.SetContext(InputContextId.Dialog);
|
||
|
||
// 确认框关闭后恢复 UI
|
||
GameEntry.InputModule.SetUIContext();
|
||
```
|
||
|
||
---
|
||
|
||
## 上下文栈语义
|
||
|
||
栈的作用是**记住恢复历史**,而非同时启用所有上下文。`ApplyContextState()` 只启用 Global + 栈顶上下文:
|
||
|
||
- `SetContext(X)`:清空栈,压入 X → 只有 Global + X 生效
|
||
- `PushContext(X)`:压入 X → 只有 Global + X 生效,之前的上下文被暂停
|
||
- `PopContext()`:弹出栈顶 → 恢复到前一个上下文
|
||
- `ClearContexts()`:清空栈 → 只有 Global 生效
|
||
|
||
这意味着 `PushContext(Dialog)` 可以安全地用在任何上下文之上,不需要担心绑定重叠。
|
||
|
||
## Dialog 上下文说明
|
||
|
||
- Dialog action map 提供 `Navigate` / `Confirm` / `Cancel` 三个 action,绑定与 UI map 一致
|
||
- 从任何上下文切换到 Dialog 都可用 `PushContext(Dialog)`,栈顶语义保证只有 Dialog 生效
|
||
- Dialog 关闭后,用 `PopContext()` 恢复之前的上下文,或用 `SetContext()` 指定目标上下文
|
||
|
||
---
|
||
|
||
## 当前 Phase 1 提供的能力
|
||
|
||
- 默认 `InputActionAsset` 代码生成
|
||
- `Global / UI / GameplayExplore / Dialog` 四组 action map
|
||
- 设备识别:
|
||
- `KeyboardMouse`
|
||
- `Gamepad`
|
||
- `Touch`
|
||
- 输入上下文栈
|
||
- 命令分发
|
||
- 绑定覆盖保存 / 加载骨架
|
||
|
||
---
|
||
|
||
## Phase 2:UI 与 Gameplay 协作能力
|
||
|
||
### 设计约定
|
||
|
||
**上下文切换采用混合方案:**
|
||
|
||
| 层级 | 职责 | 方式 |
|
||
|------|------|------|
|
||
| Procedure | 大场景级切换(Menu ↔ Main) | `SetContext()` 清栈重置 |
|
||
| UIForm | 覆盖层管理(Dialog 弹出/关闭) | `PushContext()` / `PopContext()` 压栈弹栈 |
|
||
|
||
**UI 阻断 Gameplay 输入**:栈语义天然支持。`ApplyContextState()` 只启用 Global + 栈顶上下文,当 Dialog 压栈后 GameplayExplore 自动禁用。
|
||
|
||
---
|
||
|
||
### Procedure 层接入模板
|
||
|
||
在启动 Procedure 中初始化 InputModule,在业务 Procedure 中设置上下文。
|
||
|
||
**ProcedurePreload(或你选择的启动 Procedure):**
|
||
|
||
```csharp
|
||
protected override void OnEnter(ProcedureOwner procedureOwner)
|
||
{
|
||
base.OnEnter(procedureOwner);
|
||
// ... 其他初始化 ...
|
||
GameEntry.InputModule?.OnInit();
|
||
}
|
||
```
|
||
|
||
**ProcedureMenu(进入菜单):**
|
||
|
||
```csharp
|
||
protected override void OnEnter(ProcedureOwner procedureOwner)
|
||
{
|
||
base.OnEnter(procedureOwner);
|
||
GameEntry.InputModule?.SetContext(InputContextId.UI);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
**ProcedureMain(进入游戏):**
|
||
|
||
```csharp
|
||
protected override void OnEnter(ProcedureOwner procedureOwner)
|
||
{
|
||
base.OnEnter(procedureOwner);
|
||
GameEntry.InputModule?.SetContext(InputContextId.GameplayExplore);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### UIForm 层接入模板(InputAwareGuiForm)
|
||
|
||
`InputAwareGuiForm` 是一个可选的 UIForm 基类,自动管理输入上下文的 Push/Pop。将以下代码复制到你的项目中(例如 `Assets/GameMain/Scripts/UI/InputAwareGuiForm.cs`):
|
||
|
||
```csharp
|
||
using SepCore.InputModule;
|
||
|
||
namespace SepCore.UI
|
||
{
|
||
public abstract class InputAwareGuiForm : UGuiForm
|
||
{
|
||
protected virtual InputContextId InputContext => InputContextId.None;
|
||
|
||
#if UNITY_2017_3_OR_NEWER
|
||
protected override void OnOpen(object userData)
|
||
#else
|
||
protected internal override void OnOpen(object userData)
|
||
#endif
|
||
{
|
||
base.OnOpen(userData);
|
||
|
||
if (InputContext != InputContextId.None)
|
||
{
|
||
GameEntry.InputModule?.PushContext(InputContext);
|
||
}
|
||
}
|
||
|
||
#if UNITY_2017_3_OR_NEWER
|
||
protected override void OnClose(bool isShutdown, object userData)
|
||
#else
|
||
protected internal override void OnClose(bool isShutdown, object userData)
|
||
#endif
|
||
{
|
||
if (!isShutdown && InputContext != InputContextId.None)
|
||
{
|
||
GameEntry.InputModule?.PopContext();
|
||
}
|
||
|
||
base.OnClose(isShutdown, userData);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**使用方式:** 让需要输入上下文管理的 UIForm 继承 `InputAwareGuiForm` 替代 `UGuiForm`,覆写 `InputContext` 属性。
|
||
|
||
**DialogForm 示例:**
|
||
|
||
```csharp
|
||
public class DialogForm : InputAwareGuiForm
|
||
{
|
||
protected override InputContextId InputContext => InputContextId.Dialog;
|
||
// ... 其余不变
|
||
}
|
||
```
|
||
|
||
**说明:**
|
||
|
||
- `InputContext` 返回 `None` 时不做任何上下文操作(默认行为,等同于直接继承 `UGuiForm`)
|
||
- `isShutdown` 时跳过 Pop,避免游戏关闭时栈状态不一致
|
||
- 所有调用使用 `?.` 运算符,InputModule 未接入时行为不变
|
||
|
||
---
|
||
|
||
## 业务接入约束
|
||
|
||
### 1. 不要直接读取具体设备
|
||
|
||
业务层代码应该消费 `InputCommand` 提供的语义信息,而不是直接查询当前设备类型。
|
||
|
||
**推荐:**
|
||
```csharp
|
||
GameEntry.InputModule.RegisterListener(InputActionId.Move, OnMove);
|
||
|
||
private void OnMove(InputCommand cmd)
|
||
{
|
||
// 消费 Vector2 值,不关心来自键盘还是手柄
|
||
Vector2 direction = cmd.Vector2Value;
|
||
}
|
||
```
|
||
|
||
**避免:**
|
||
```csharp
|
||
// 不推荐:业务层直接判断设备类型
|
||
if (GameEntry.InputModule.CurrentDeviceKind == InputDeviceKind.Gamepad)
|
||
{
|
||
// 设备相关的差异化逻辑应该放在 presentation 层或专门的适配层
|
||
}
|
||
```
|
||
|
||
**原因:**
|
||
- 设备类型是运行时动态切换的(键鼠 ↔ 手柄热插拔)
|
||
- 业务逻辑应该与输入设备解耦,只关心“玩家发出了什么语义指令”
|
||
- 如果确实需要设备提示(如 UI 按钮图标切换),应在 UI 层监听 `DeviceKindChanged` 事件,而不是在 gameplay 业务中判断
|
||
|
||
### 2. UI 原始输入继续走 EventSystem
|
||
|
||
InputModule 不接管 UI 原始输入(点击、拖拽、滚动、导航)。UI 交互继续由 Unity EventSystem 处理。
|
||
|
||
**分工:**
|
||
- **UI 点击 / 拖拽 / 滚动** → `EventSystem` / `InputSystemUIInputModule`
|
||
- **Gameplay 语义输入**(Move、Interact、Sprint) → `InputModuleComponent`
|
||
- **UI 打开 / 关闭** → 只负责切换 gameplay context(如 Push/Pop Dialog),不负责转发 UI 事件
|
||
|
||
**原因:**
|
||
- EventSystem 已经成熟处理 UI 交互,不需要重复实现
|
||
- PC 鼠标指针、Mobile 触摸、主机导航键各自有不同的 UI 处理路径
|
||
- 强行统一会引入双重分发,增加维护复杂度
|
||
|
||
### 3. UI 打开 / 关闭只切换 context,不接管事件分发
|
||
|
||
UIForm / Procedure 通过 `SetContext` / `PushContext` / `PopContext` 管理 gameplay 输入的启用与暂停,但不应该尝试通过 InputModule 分发 UI 事件。
|
||
|
||
**正确示例:**
|
||
```csharp
|
||
// DialogForm 打开时暂停 gameplay 输入
|
||
GameEntry.InputModule.PushContext(InputContextId.Dialog);
|
||
|
||
// DialogForm 中的按钮点击仍然走 UnityEvent / EventSystem
|
||
_confirmButton.onClick.AddListener(OnConfirm);
|
||
```
|
||
|
||
**错误示例:**
|
||
```csharp
|
||
// 不要这样做:试图用 InputModule 替代按钮点击
|
||
GameEntry.InputModule.RegisterListener(InputActionId.Confirm, _ =>
|
||
{
|
||
// 不要在 UI 内部用 Confirm action 模拟按钮点击
|
||
// 这会导致 EventSystem 和 InputModule 双重响应
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 平台输入职责划分
|
||
|
||
### PC / 主机
|
||
|
||
- Gameplay 输入:优先走 `InputModule`
|
||
- UI:优先走 `EventSystem / InputSystemUIInputModule`
|
||
- 若未来确实需要主机平台专门的 UI 统一入口,再单独设计和验证
|
||
|
||
### 移动端
|
||
|
||
- 普通 UI 点击 / 拖拽 / 滚动:直接走 Unity EventSystem
|
||
- 虚拟摇杆 / 虚拟按钮:通过 `VirtualJoystickBridge` / `VirtualButtonBridge` 适配进 InputModule
|
||
- 不要求把每一次触屏 UI 操作都抽象成 `Confirm / Cancel / Navigate`
|
||
|
||
---
|
||
|
||
## 当前 Phase 1 不负责的内容
|
||
|
||
- 基座默认自动接入
|
||
- 自动修改 `GameEntry` / Procedure
|
||
- 交互式重绑界面
|
||
- 具体业务语义绑定(例如战斗输入、菜单输入、剧情输入)
|
||
|
||
这些都应该在后续阶段或接入方项目里完成。
|
||
|
||
---
|
||
|
||
## 为什么不能用 `InputModuleEntry`
|
||
|
||
像 `InputModuleEntry` 这种插件内部静态入口,看起来方便,但不符合这里的目标:
|
||
|
||
1. 它绕过了项目自己的 `GameEntry` 接入约定
|
||
2. 它弱化了“这是一个可选模块,需要显式接入”的边界
|
||
3. 它会让模块拥有一套平行于基座的入口体系,后续维护容易混乱
|
||
|
||
所以这里统一采用:
|
||
|
||
- **模块提供 `InputModuleComponent`**
|
||
- **项目自己决定是否在 `GameEntry` 中暴露 `InputModule`**
|
||
- **项目自己决定在哪个 Procedure 里调用 `OnInit()`**
|
||
- **模块 Runtime 代码通过 asmref 并入 `SepCore.Runtime`,与基座 Component 平级协作**
|
||
|
||
这才符合”可选模块”的接入方式。
|
||
|
||
---
|
||
|
||
## P4:设备适配体验
|
||
|
||
### 按钮提示映射表(Prompt Map)
|
||
|
||
`InputModuleComponent` 提供了一个可选的 prompt map 系统,用于获取”当前设备下某 action 的按键提示”。
|
||
|
||
**核心类型:**
|
||
|
||
- `InputPrompt`(Base 层 readonly struct):承载提示数据
|
||
- `TextLabel`:文本标签(如 `”A”`, `”Enter”`, `”E”`)
|
||
- `SpriteName`:可选的 sprite 资源键(项目自定义,模块不管理 sprite 资源);多个 sprite 可用 `|` 分隔,如 `keyboard_w|keyboard_a|keyboard_s|keyboard_d`
|
||
- `HasSprite` / `IsValid`:便捷检查属性
|
||
- `IInputPromptMap`(Base 层接口):项目可自定义实现
|
||
- `TryGetPrompt(InputActionId, InputDeviceKind, out InputPrompt)`:查找提示
|
||
|
||
**默认映射表(Xbox 惯例):**
|
||
|
||
| InputActionId | KeyboardMouse | Gamepad | Touch |
|
||
|---|---|---|---|
|
||
| Pause | `Esc` | `Menu` | `Pause` |
|
||
| Navigate | `WASD` | `L Stick` | `Swipe` |
|
||
| Confirm | `Enter` | `A` | `Tap` |
|
||
| Cancel | `Esc` | `B` | `Back` |
|
||
| Move | `WASD` | `L Stick` | `Joystick` |
|
||
| Sprint | `L Shift` | `L3` | *(无)* |
|
||
| Interact | `E` | `A` | `Tap` |
|
||
|
||
**使用方式:**
|
||
|
||
```csharp
|
||
// 便捷方法:自动使用当前设备类型
|
||
if (GameEntry.InputModule.TryGetPrompt(InputActionId.Interact, out InputPrompt prompt))
|
||
{
|
||
_interactLabel.text = prompt.TextLabel;
|
||
}
|
||
|
||
// 直接查询映射表:指定设备类型
|
||
IInputPromptMap map = GameEntry.InputModule.PromptMap;
|
||
map.TryGetPrompt(InputActionId.Confirm, InputDeviceKind.Gamepad, out InputPrompt p);
|
||
|
||
// 替换为自定义映射(如 PS 命名)
|
||
GameEntry.InputModule.PromptMap = new MyPlayStationPromptMap();
|
||
```
|
||
|
||
**TMP 文本工具:**
|
||
|
||
`Assets/Plugins/InputModule/Presentation/InputPromptTextUtility.cs` 提供了将 `InputPrompt` 转成 TMP 文本的工具:
|
||
|
||
```csharp
|
||
if (GameEntry.InputModule.TryGetPrompt(InputActionId.Move, out InputPrompt prompt))
|
||
{
|
||
_movePromptText.text = InputPromptTextUtility.BuildTmpText(prompt);
|
||
}
|
||
```
|
||
|
||
- `BuildTmpText(InputPrompt)`:输出 `"<sprite name=\"keyboard_w\"> <sprite name=\"keyboard_a\"> ... 移动"` 这类可直接给 `TMP_Text.text` 的字符串
|
||
- `BuildSpriteTags(string)`:只把 `SpriteName` 转成 TMP sprite tag
|
||
- `SpriteName` 使用 `|` 分隔时会输出多个 sprite tag;单个 sprite 仍按原方式输出
|
||
|
||
**自定义 PromptMap 示例:**
|
||
|
||
```csharp
|
||
public sealed class PlayStationPromptMap : IInputPromptMap
|
||
{
|
||
public bool TryGetPrompt(InputActionId actionId, InputDeviceKind deviceKind, out InputPrompt prompt)
|
||
{
|
||
if (deviceKind == InputDeviceKind.Gamepad)
|
||
{
|
||
switch (actionId)
|
||
{
|
||
case InputActionId.Confirm: prompt = new InputPrompt(“Cross”); return true;
|
||
case InputActionId.Cancel: prompt = new InputPrompt(“Circle”); return true;
|
||
// ...
|
||
}
|
||
}
|
||
|
||
// 回退到默认
|
||
prompt = InputPrompt.None;
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 提示刷新协议
|
||
|
||
**触发器:** `DeviceKindChanged` 事件。无需单独的 `PromptsChanged` 事件。
|
||
|
||
**推荐接入现有 UI 架构:**
|
||
|
||
当前项目 UI 采用 `Controller -> Context -> Form`,输入提示也应沿用这条数据流:
|
||
|
||
- Controller:监听 `GameEntry.InputModule.DeviceKindChanged`,查询 `TryGetPrompt()`,用 `InputPromptTextUtility.BuildTmpText()` 生成 TMP 文本
|
||
- Context:保存当前 UI 需要展示的提示字符串,如 `MovePrompt`、`ConfirmPrompt`、`CancelPrompt`
|
||
- Form:只负责展示,不直接判断设备类型,也不直接查询 InputModule
|
||
|
||
**Hud 示例:**
|
||
|
||
```csharp
|
||
public class HudContext : UIContext
|
||
{
|
||
public string MovePrompt { get; set; }
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
private static HudContext BuildHudContext()
|
||
{
|
||
return new HudContext
|
||
{
|
||
MovePrompt = BuildPromptText(InputActionId.Move)
|
||
};
|
||
}
|
||
|
||
private static string BuildPromptText(InputActionId actionId)
|
||
{
|
||
if (GameEntry.InputModule == null || !GameEntry.InputModule.TryGetPrompt(actionId, out InputPrompt prompt))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return InputPromptTextUtility.BuildTmpText(prompt);
|
||
}
|
||
|
||
protected override void SubscribeCustomEvents()
|
||
{
|
||
GameEntry.InputModule.DeviceKindChanged += OnDeviceKindChanged;
|
||
}
|
||
|
||
protected override void UnsubscribeCustomEvents()
|
||
{
|
||
GameEntry.InputModule.DeviceKindChanged -= OnDeviceKindChanged;
|
||
}
|
||
|
||
private void OnDeviceKindChanged(InputDeviceKind deviceKind)
|
||
{
|
||
if (Context == null || Form == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Context.MovePrompt = BuildPromptText(InputActionId.Move);
|
||
Form.RefreshMovePrompt(Context.MovePrompt);
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
public void RefreshMovePrompt(string prompt)
|
||
{
|
||
bool visible = !string.IsNullOrEmpty(prompt);
|
||
_movePromptRoot.SetActive(visible);
|
||
_movePromptText.text = prompt;
|
||
}
|
||
```
|
||
|
||
**为什么不需要单独的 PromptsChanged 事件?**
|
||
- `DeviceKindChanged` 已经捕获了唯一会改变提示的场景
|
||
- 如果未来需要”重绑后刷新提示”(不切换设备),可以届时再加新事件
|
||
|
||
### UI 展示约定
|
||
|
||
**KeyboardMouse:**
|
||
- 显示文本标签(”E”, “Enter”, “Esc”)
|
||
- 推荐等宽字体或按键帽风格
|
||
- 可选:显示 key-cap sprite(如果 `InputPrompt.HasSprite` 为 true)
|
||
|
||
**Gamepad:**
|
||
- 显示按钮名(”A”, “B”, “L Stick”)
|
||
- 默认使用 Xbox 惯例,PS 项目替换自定义映射
|
||
- 可选:显示按钮 sprite
|
||
|
||
**Touch:**
|
||
- 显示手势描述(”Tap”, “Swipe”)
|
||
- 主要用于教程/引导
|
||
- Sprint 无默认 touch 提示(虚拟按钮为 P5 范围)
|
||
|
||
**Unknown:**
|
||
- 隐藏提示元素,或显示通用兜底”Press any button”
|
||
- Unknown 状态仅在启动时、首次输入前出现
|
||
|
||
**通用规则:**
|
||
- 不要硬编码提示字符串,始终查询 PromptMap
|
||
- `TryGetPrompt` 返回 false 时隐藏或显示兜底
|
||
|
||
### 灵敏度/死区配置
|
||
|
||
**评估结论:暂不实现。**
|
||
|
||
理由:
|
||
1. InputSystem 内置 `StickDeadzone`(默认 0.125)和 `AxisDeadzone` 处理器已自动应用
|
||
2. 项目中无任何灵敏度/死区相关代码,属于推测性需求
|
||
3. 当前范围为 PC + 主机,键盘/鼠标无灵敏度概念,手柄使用 InputSystem 默认值已足够
|
||
4. 移动端(虚拟摇杆死区)为 P5 范围
|
||
|
||
未来如需实现:Base 层加 `InputSensitivityConfig` 数据结构,Runtime 层在 `EnsureInitialized()` 中注入自定义 Processor。
|
||
|
||
---
|
||
|
||
### 可选:TMP Sprite Asset(输入提示图标)
|
||
|
||
`Assets/Plugins/InputModule/Input_Prompts/` 包含一个预制的 TMP Sprite Asset,可用于在 TextMeshPro 中显示按键图标。
|
||
|
||
**文件说明:**
|
||
|
||
- `InputPrompt.asset` — TMP Sprite Asset,包含键盘、手柄、鼠标等按键图标
|
||
- `Input_Prompt.png` — 对应的 Sprite 图集(512x512,16x16 单元格)
|
||
|
||
**完整 Sprite 名称对照表:**
|
||
|
||
| Sprite Name | 说明 | Sprite Name | 说明 |
|
||
|-------------|------|-------------|------|
|
||
| `gamepad_button` | 通用手柄按钮(占位) | `gamepad_a` | A 键 |
|
||
| `gamepad_b` | B 键 | `gamepad_x` | X 键 |
|
||
| `gamepad_y` | Y 键 | `gamepad_map` | Map / View 键 |
|
||
| `gamepad_menu` | Menu / Start 键 | `gamepad_control` | 方向键(十字键) |
|
||
| `gamepad_up_control` | 方向键上 | `gamepad_right_control` | 方向键右 |
|
||
| `gamepad_down_control` | 方向键下 | `gamepad_left_control` | 方向键左 |
|
||
| `gamepad_horizon_control` | 方向键水平(占位) | `gamepad_vertical_control` | 方向键垂直(占位) |
|
||
| `gamepad_full_control` | 方向键全方向(占位) | `gamepad_ls` | 左摇杆 |
|
||
| `gamepad_up_ls` | 左摇杆上推 | `gamepad_right_ls` | 左摇杆右推 |
|
||
| `gamepad_down_ls` | 左摇杆下推 | `gamepad_left_ls` | 左摇杆左推 |
|
||
| `gamepad_horizon_ls` | 左摇杆水平(占位) | `gamepad_vertical_ls` | 左摇杆垂直(占位) |
|
||
| `gamepad_full_ls` | 左摇杆全方向(占位) | `gamepad_press_ls` | 左摇杆按下(L3) |
|
||
| `gamepad_rs` | 右摇杆 | `gamepad_up_rs` | 右摇杆上推 |
|
||
| `gamepad_right_rs` | 右摇杆右推 | `gamepad_down_rs` | 右摇杆下推 |
|
||
| `gamepad_left_rs` | 右摇杆左推 | `gamepad_horizon_rs` | 右摇杆水平(占位) |
|
||
| `gamepad_vertical_rs` | 右摇杆垂直(占位) | `gamepad_full_rs` | 右摇杆全方向(占位) |
|
||
| `gamepad_press_rs` | 右摇杆按下(R3) | `gamepad_left_trigger` | 左扳机(LT) |
|
||
| `gamepad_right_trigger` | 右扳机(RT) | `gamepad_left_bumper` | 左肩键(LB) |
|
||
| `gamepad_right_bumper` | 右肩键(RB) | | |
|
||
| `keyboard_esc` | Esc 键 | `keyboard_f1` ~ `keyboard_f12` | F1 ~ F12 |
|
||
| `keyboard_1` ~ `keyboard_0` | 1 ~ 0 | `keyboard_minus` | 减号 / 连字符 |
|
||
| `keyboard_equal` | 等号 | `keyboard_backspace` | 退格键 |
|
||
| `keyboard_q` ~ `keyboard_p` | Q ~ P | `keyboard_left_bracket` | 左方括号 `[` |
|
||
| `keyboard_right_bracket` | 右方括号 `]` | `keyboard_backslash` | 反斜杠 `\` |
|
||
| `keyboard_a` ~ `keyboard_l` | A ~ L | `keyboard_single_quote` | 单引号 `'` |
|
||
| `keyboard_semicolon` | 分号 `;` | `keyboard_enter` | 回车键 |
|
||
| `keyboard_z` ~ `keyboard_m` | Z ~ M | `keyboard_slash` | 斜杠 `/` |
|
||
| `keyboard_up_arrow` | 上箭头 | `keyboard_right_arrow` | 右箭头 |
|
||
| `keyboard_down_arrow` | 下箭头 | `keyboard_left_arrow` | 左箭头 |
|
||
| `keyboard_alt` | Alt 键 | `keyboard_tab` | Tab 键 |
|
||
| `keyboard_delete` | Delete 键 | `keyboard_period` | 句号 `.` |
|
||
| `keyboard_control` | Ctrl 键 | `keyboard_capslock` | Caps Lock |
|
||
| `keyboard_comma` | 逗号 `,` | `keyboard_space` | 空格键 |
|
||
| `keyboard_shift` | Shift 键 | | |
|
||
| `mouse` | 鼠标指针 | `mouse_left` | 鼠标左键 |
|
||
| `mouse_right` | 鼠标右键 | `mouse_middle` | 鼠标中键 |
|
||
| `mouse_scroll_up` | 滚轮上滚 | `mouse_scroll_down` | 滚轮下滚 |
|
||
| `mouse_scroll` | 滚轮(占位) | | |
|
||
|
||
**接入方式(可选):**
|
||
|
||
若项目需要在 UI 中显示按键图标(而非纯文本),将 `InputPrompt.asset` 设为 TMP 的 Default Sprite Asset:
|
||
|
||
1. 打开 **Edit > Project Settings > TextMeshPro**
|
||
2. 在 **Default Sprite Asset** 中拖入 `Assets/Plugins/InputModule/Input_Prompts/InputPrompt.asset`
|
||
|
||
**在文本中使用:**
|
||
|
||
```
|
||
按 <sprite="InputPrompt" name=keyboard_e> 交互
|
||
按 <sprite="InputPrompt" name=gamepad_a> 确认
|
||
```
|
||
|
||
**与 PromptMap 配合:**
|
||
|
||
```csharp
|
||
if (GameEntry.InputModule.TryGetPrompt(InputActionId.Interact, out InputPrompt prompt))
|
||
{
|
||
_label.text = InputPromptTextUtility.BuildTmpText(prompt);
|
||
}
|
||
```
|
||
|
||
如果只想显示图标,可以使用:
|
||
|
||
```csharp
|
||
_iconText.text = InputPromptTextUtility.BuildSpriteTags(prompt.SpriteName);
|
||
```
|
||
|
||
**说明:**
|
||
|
||
- 此步骤完全可选;不设置 Default Sprite Asset 不影响任何核心功能
|
||
- 若项目已有自己的按键图标系统,可直接忽略此文件夹
|
||
- `SpriteName` 的值由 `IInputPromptMap` 实现决定,需与 `InputPrompt.asset` 中的 sprite name 保持一致
|
||
|
||
---
|
||
|
||
## P5:移动端扩展
|
||
|
||
### 架构
|
||
|
||
移动端输入沿用 P2 确定的边界:
|
||
|
||
- **普通 UI 点击 / 拖拽 / 滚动** → 继续走 `EventSystem / InputSystemUIInputModule`
|
||
- **Gameplay 语义输入**(Move / Sprint / Interact)→ 通过虚拟摇杆 / 虚拟按钮桥接进 `InputModuleComponent`
|
||
|
||
虚拟控件代码位于 `Assets/Plugins/InputModule/Presentation/`,通过 `SepCore.Presentation.InputModule.asmref` 编译进 `SepCore.Presentation`,与基座零耦合。
|
||
|
||
### 虚拟摇杆(VirtualJoystickBridge)
|
||
|
||
**组件位置:** `SepCore.InputModule.Runtime.VirtualInput.VirtualJoystickBridge`
|
||
|
||
**挂载要求:** 挂在带有 `Joystick`(或 `VariableJoystick` / `FloatingJoystick` / `DynamicJoystick`)的 GameObject 上。
|
||
|
||
**字段:**
|
||
|
||
| 字段 | 说明 | 默认值 |
|
||
|------|------|--------|
|
||
| `_actionId` | 映射的 Action(如 `Move`) | `Move` |
|
||
| `_contextId` | 所属上下文 | `GameplayExplore` |
|
||
| `_injectDeviceKind` | 触摸时是否强制设备类型为 `Touch` | `true` |
|
||
|
||
**使用方式:**
|
||
|
||
1. 在 Canvas 下创建空 GameObject
|
||
2. 挂载 `VariableJoystick`(或 `FloatingJoystick` / `FixedJoystick`)
|
||
3. 在同 GameObject 上挂载 `VirtualJoystickBridge`
|
||
4. 配置 `_actionId` 为 `Move`
|
||
|
||
```csharp
|
||
// 业务层无需额外代码
|
||
// 摇杆输入会自动通过 CommandTriggered / RegisterListener 分发
|
||
GameEntry.InputModule.RegisterListener(InputActionId.Move, OnMove);
|
||
|
||
private void OnMove(InputCommand cmd)
|
||
{
|
||
Vector2 direction = cmd.Vector2Value;
|
||
// 与键盘/手柄输入完全一致
|
||
}
|
||
```
|
||
|
||
### 虚拟按钮(VirtualButtonBridge)
|
||
|
||
**组件位置:** `SepCore.InputModule.Runtime.VirtualInput.VirtualButtonBridge`
|
||
|
||
**挂载要求:** 挂在 UI 按钮(或任意带 `Collider2D` / `RectTransform` 的交互元素)上。需要场景中已有 `EventSystem`。
|
||
|
||
**字段:**
|
||
|
||
| 字段 | 说明 | 默认值 |
|
||
|------|------|--------|
|
||
| `_actionId` | 映射的 Action(如 `Sprint` / `Interact`) | — |
|
||
| `_contextId` | 所属上下文 | `GameplayExplore` |
|
||
| `_injectDeviceKind` | 触摸时是否强制设备类型为 `Touch` | `true` |
|
||
|
||
**使用方式:**
|
||
|
||
1. 在 Canvas 下创建 UI 按钮 GameObject
|
||
2. 挂载 `VirtualButtonBridge`
|
||
3. 配置 `_actionId` 为 `Sprint` 或 `Interact`
|
||
|
||
```csharp
|
||
GameEntry.InputModule.RegisterListener(InputActionId.Sprint, OnSprint);
|
||
```
|
||
|
||
### 设备切换
|
||
|
||
当玩家触摸虚拟摇杆或虚拟按钮时:
|
||
|
||
- `VirtualJoystickBridge` / `VirtualButtonBridge` 自动调用 `GameEntry.InputModule.ForceDeviceKind(InputDeviceKind.Touch)`
|
||
- 触发 `DeviceKindChanged` 事件
|
||
- UI 层监听该事件,刷新按键提示(如将 "E" 切换为 "Tap")
|
||
|
||
### 虚拟控件显示 / 隐藏
|
||
|
||
由业务层根据 `DeviceKindChanged` 控制,InputModule 不强制管理:
|
||
|
||
```csharp
|
||
private void OnDeviceKindChanged(InputDeviceKind kind)
|
||
{
|
||
_joystickCanvas.SetActive(kind == InputDeviceKind.Touch);
|
||
}
|
||
```
|
||
|
||
推荐策略:
|
||
|
||
- PC / 主机:隐藏虚拟控件
|
||
- 移动端(检测到 `Touch`):显示虚拟控件
|
||
- 键鼠 ↔ 手柄热插拔时不影响虚拟控件显隐(只有 `Touch` 才显示)
|
||
|
||
### 预制体
|
||
|
||
`Assets/Plugins/InputModule/Assets/Joystick/Prefabs/` 提供四种摇杆预制体:
|
||
|
||
- `Fixed Joystick`:固定位置
|
||
- `Floating Joystick`:按下时于触点浮现
|
||
- `Dynamic Joystick`:按下时浮现,拖动超过阈值后背景跟随移动
|
||
- `Variable Joystick`:运行时可通过 `SetMode(JoystickType)` 切换以上三种模式
|
||
|
||
可直接拖入 Canvas 使用。
|
||
|
||
### 与 Joystick Pack 的关系
|
||
|
||
`Assets/Plugins/InputModule/Presentation/JoystickPack/` 下的代码是 Joystick Pack 的副本(已修复 `AxisOptions` 递归属性 bug)。这些代码通过 `SepCore.Presentation.InputModule.asmref` 编译进 `SepCore.Presentation`,不修改基座程序集引用。
|
||
|
||
如果未来需要更新 Joystick Pack:
|
||
|
||
1. 替换 `JoystickPack/` 下的脚本文件
|
||
2. 重新修复第24行的 `AxisOptions` 属性 bug
|