2026-03-26 17:39:53 +08:00
|
|
|
|
# Input Flow And Frame Semantics
|
|
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
## 这篇指南解决什么问题
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
这篇文档不是“列 API 名称”,而是解释 XCEngine 当前输入系统到底按什么规则工作。它直接基于当前这些实现和测试:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- `engine/src/Input/InputManager.cpp`
|
|
|
|
|
|
- `engine/include/XCEngine/Input/InputManager.h`
|
|
|
|
|
|
- `tests/Input/test_input_manager.cpp`
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
如果你在游戏层、编辑器运行态桥接、脚本绑定或平台输入后端里使用 `InputManager`,最重要的不是记住方法名,而是先建立一致的语义模型。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
## 设计理念
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
当前输入系统刻意做成两层:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- `InputModule`
|
|
|
|
|
|
- 负责接平台消息,把 `WM_KEYDOWN`、`WM_MOUSEMOVE` 这类原始系统消息翻译成引擎调用
|
|
|
|
|
|
- `InputManager`
|
|
|
|
|
|
- 负责保存状态、提供轮询 API、同步分发事件
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
这种设计接近商业引擎里常见的“平台适配层 + 游戏语义层”分工。好处是:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- 游戏逻辑和编辑器逻辑不需要直接依赖 Win32 之类的平台细节
|
|
|
|
|
|
- 平台后端可以替换,输入查询接口可以保持稳定
|
|
|
|
|
|
- 轮询和事件可以共享同一份底层状态,不需要上层再维护第二套缓存
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
从使用风格上看,它也明显借鉴了 Unity 旧版 `Input Manager` 的思想:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- 支持具名轴,如 `Horizontal`、`Vertical`
|
|
|
|
|
|
- 支持具名按钮,如 `Jump`、`Fire1`
|
|
|
|
|
|
- 以 `GetAxis()`、`GetButton()` 一类游戏层查询接口为中心
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
但它当前还是一个轻量级、同步、帧驱动的输入管理器,不是完整的设备抽象框架。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
## 输入流到底怎么走
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
当前一帧内的典型流向是:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
1. 平台窗口系统产生键盘、鼠标消息。
|
|
|
|
|
|
2. 平台后端把消息翻译成 `ProcessKeyDown()`、`ProcessKeyUp()`、`ProcessMouseMove()`、`ProcessMouseButton()`、`ProcessMouseWheel()` 等调用。
|
|
|
|
|
|
3. `InputManager` 立即更新内部缓存,并同步触发 `OnKeyEvent()`、`OnMouseMove()` 等事件。
|
|
|
|
|
|
4. 游戏逻辑、编辑器运行态桥接或脚本绑定在当前帧查询 `IsKeyDown()`、`GetAxis()`、`GetButtonDown()` 等 API。
|
|
|
|
|
|
5. 帧末或统一帧边界调用 `Update()`,清空瞬时输入缓存,准备下一帧。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
这说明一个关键点:`Update()` 不是“收集输入”,而是“推进帧边界并清空瞬时状态”。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
## 为什么同时提供事件和轮询
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
商业级引擎通常不会强迫所有系统只用一种输入风格。XCEngine 当前的取舍也是一样:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- 事件更适合 UI、文本输入、编辑器面板、平台桥接
|
|
|
|
|
|
- 轮询更适合角色移动、相机控制、游戏规则判断
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
`InputManager` 把两者放在同一个状态中心里,能减少几类常见问题:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- 上层不需要再手写“本帧按下 / 本帧释放”缓存
|
|
|
|
|
|
- 事件订阅者和轮询调用者看到的是同一组真实状态
|
|
|
|
|
|
- 平台输入桥接和游戏输入消费不需要重复实现状态机
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
## 帧边界语义
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
当前实现里,`Update()` 会清理这些瞬时状态:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- `m_keyDownThisFrame`
|
|
|
|
|
|
- `m_keyUpThisFrame`
|
|
|
|
|
|
- `m_mouseButtonDownThisFrame`
|
|
|
|
|
|
- `m_mouseButtonUpThisFrame`
|
|
|
|
|
|
- `m_mouseDelta`
|
|
|
|
|
|
- `m_mouseScrollDelta`
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
因此下面这些接口都天然是“本帧有效,过帧清空”:
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- `IsKeyPressed()`
|
|
|
|
|
|
- `IsKeyReleased()`
|
|
|
|
|
|
- `IsMouseButtonClicked()`
|
|
|
|
|
|
- `IsMouseButtonReleased()`
|
|
|
|
|
|
- `GetButtonDown()`
|
|
|
|
|
|
- `GetButtonUp()`
|
|
|
|
|
|
- `IsAnyKeyPressed()`
|
|
|
|
|
|
- `GetMouseDelta()`
|
|
|
|
|
|
- `GetMouseScrollDelta()`
|
|
|
|
|
|
|
|
|
|
|
|
推荐实践只有三条,但都很重要:
|
|
|
|
|
|
|
|
|
|
|
|
1. 每帧只调用一次 `Update()`
|
|
|
|
|
|
2. 保持调用时机稳定,不要一会儿逻辑前一会儿逻辑后
|
|
|
|
|
|
3. 先消费瞬时输入,再执行 `Update()`
|
|
|
|
|
|
|
|
|
|
|
|
## 四类最常用的输入语义
|
|
|
|
|
|
|
|
|
|
|
|
先按语义理解 API,比按方法名死记更可靠。
|
|
|
|
|
|
|
|
|
|
|
|
| 语义 | 典型 API | 当前真正含义 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| 按住态 | `IsKeyDown()`、`IsMouseButtonDown()`、`GetButton()`、`GetAxis()`、`GetAxisRaw()`、`IsAnyKeyDown()` | 当前是否仍处于按住状态 |
|
|
|
|
|
|
| 抬起态 | `IsKeyUp()`、`IsMouseButtonUp()` | 当前没有按住,不包含“本帧刚释放”含义 |
|
|
|
|
|
|
| 按下边沿 | `IsKeyPressed()`、`IsMouseButtonClicked()`、`GetButtonDown()`、`IsAnyKeyPressed()` | 本帧出现按下消息 |
|
|
|
|
|
|
| 释放边沿 | `IsKeyReleased()`、`IsMouseButtonReleased()`、`GetButtonUp()` | 本帧出现释放消息 |
|
|
|
|
|
|
|
|
|
|
|
|
这也是很多团队最容易踩错的地方:`Up` 和 `Released` 不是同义词。
|
|
|
|
|
|
|
|
|
|
|
|
## `Up` 和 `Released` 为什么必须分开
|
|
|
|
|
|
|
|
|
|
|
|
这是当前模块最需要讲透的概念。
|
|
|
|
|
|
|
|
|
|
|
|
- `IsKeyUp()` / `IsMouseButtonUp()`
|
|
|
|
|
|
- 只表示“现在没有按住”
|
|
|
|
|
|
- 本质上等价于 `!IsKeyDown()` / `!IsMouseButtonDown()`
|
|
|
|
|
|
- 即使未初始化或索引越界,当前实现里也会表现为 `true`
|
|
|
|
|
|
- `IsKeyReleased()` / `IsMouseButtonReleased()`
|
|
|
|
|
|
- 只表示“这一帧刚收到释放消息”
|
|
|
|
|
|
- 由 `ProcessKeyUp()` / `ProcessMouseButton(..., false, ...)` 写入
|
|
|
|
|
|
- 会在下一次 `Update()` 时被清空
|
|
|
|
|
|
|
|
|
|
|
|
为什么商业引擎通常都要保留这两套语义:
|
|
|
|
|
|
|
|
|
|
|
|
- “当前没按住”适合状态判断
|
|
|
|
|
|
- “这一帧刚释放”适合一次性触发逻辑
|
|
|
|
|
|
|
|
|
|
|
|
例如“角色是否仍在蓄力”更像 `IsKeyDown()` / `IsKeyUp()` 问题;“松开蓄力键时发射一次”则应该看 `IsKeyReleased()`。
|
|
|
|
|
|
|
|
|
|
|
|
## `Pressed` 当前到底是不是“首次按下”
|
|
|
|
|
|
|
|
|
|
|
|
不是严格意义上的“首次物理按下”。
|
|
|
|
|
|
|
|
|
|
|
|
源码里 `ProcessKeyDown()` 的当前行为是:
|
|
|
|
|
|
|
|
|
|
|
|
- 无论 `repeat` 为 `true` 还是 `false`
|
|
|
|
|
|
- 都会把 `m_keyDown[index] = true`
|
|
|
|
|
|
- 也都会把 `m_keyDownThisFrame[index] = true`
|
|
|
|
|
|
|
|
|
|
|
|
这带来一个很关键的结果:
|
|
|
|
|
|
|
|
|
|
|
|
- `IsKeyPressed()`
|
|
|
|
|
|
- `GetButtonDown()`
|
|
|
|
|
|
- `IsAnyKeyPressed()`
|
|
|
|
|
|
|
|
|
|
|
|
它们当前表达的更准确含义是“这一帧收到了按下消息”,而不是“从这次物理按下开始到松开之前只触发一次”。
|
|
|
|
|
|
|
|
|
|
|
|
如果平台在后续帧继续发送自动重复 `KeyDown`:
|
|
|
|
|
|
|
|
|
|
|
|
- `OnKeyEvent()` 会把事件类型标成 `KeyEvent::Repeat`
|
|
|
|
|
|
- 但按下边沿缓存仍会被写入
|
|
|
|
|
|
- 所以上述查询接口仍可能在这些帧再次返回 `true`
|
|
|
|
|
|
|
|
|
|
|
|
如果业务真的需要“严格首次按下”,需要在平台层或更高层自己过滤 repeat。
|
|
|
|
|
|
|
|
|
|
|
|
## 轴和按钮映射的真实行为
|
|
|
|
|
|
|
|
|
|
|
|
### 逻辑轴
|
|
|
|
|
|
|
|
|
|
|
|
当前 `RegisterAxis()` 把一个名字映射到“正方向键 + 负方向键”。
|
|
|
|
|
|
|
|
|
|
|
|
`GetAxis()` 和 `GetAxisRaw()` 目前都直接读 `IsKeyDown()`:
|
|
|
|
|
|
|
|
|
|
|
|
- 正方向键按住时加 `1.0f`
|
|
|
|
|
|
- 负方向键按住时减 `1.0f`
|
|
|
|
|
|
- 同时按下则抵消为 `0.0f`
|
|
|
|
|
|
|
|
|
|
|
|
当前两者没有行为差异。它们都:
|
|
|
|
|
|
|
|
|
|
|
|
- 不读取 `InputAxis::m_value`
|
|
|
|
|
|
- 不做平滑、重力、灵敏度或死区处理
|
|
|
|
|
|
- 不会因为只是“本帧刚按下”而产生非零值
|
|
|
|
|
|
|
|
|
|
|
|
这和很多商业引擎早期的“raw axis / smoothed axis”区分还不一样,当前实现更接近“两个名字不同但逻辑相同的持续态接口”。
|
|
|
|
|
|
|
|
|
|
|
|
### 逻辑按钮
|
|
|
|
|
|
|
|
|
|
|
|
当前 `RegisterButton()` 只是把名字映射到一个 `KeyCode`。
|
|
|
|
|
|
|
|
|
|
|
|
- `GetButton()` 转发到 `IsKeyDown()`
|
|
|
|
|
|
- `GetButtonDown()` 转发到 `IsKeyPressed()`
|
|
|
|
|
|
- `GetButtonUp()` 转发到 `IsKeyReleased()`
|
|
|
|
|
|
|
|
|
|
|
|
因此按钮系统当前不是独立状态机,而只是对键盘查询的命名包装。
|
|
|
|
|
|
|
|
|
|
|
|
## 和 Unity 的关系
|
|
|
|
|
|
|
|
|
|
|
|
如果你熟悉 Unity,这里最容易建立直觉,但也最容易因为“看起来像”而误判。
|
|
|
|
|
|
|
|
|
|
|
|
相似点:
|
|
|
|
|
|
|
|
|
|
|
|
- 都有具名轴和具名按钮
|
|
|
|
|
|
- 都强调游戏层直接查询,而不是让业务处理原始平台消息
|
|
|
|
|
|
- 都适合快速搭建角色控制、相机控制和简单交互
|
|
|
|
|
|
|
|
|
|
|
|
不同点:
|
|
|
|
|
|
|
|
|
|
|
|
- 当前没有 Unity 旧版 Input Manager 里的 gravity、sensitivity、dead zone、snap 等参数
|
|
|
|
|
|
- `GetAxisRaw()` 目前并不比 `GetAxis()` 更“底层”,两者等价
|
|
|
|
|
|
- 默认注册的 `Mouse X` / `Mouse Y` 还没有绑定到鼠标移动增量
|
|
|
|
|
|
- `GetButtonUp()` 是释放边沿,不是“当前按钮没按着就算 up”
|
|
|
|
|
|
|
|
|
|
|
|
所以,当前更适合把它理解成“Unity 风格接口外观 + XCEngine 当前真实实现语义”,而不是直接按 Unity 经验套用全部行为。
|
|
|
|
|
|
|
|
|
|
|
|
## `any-key` 目前到底统计什么
|
|
|
|
|
|
|
|
|
|
|
|
当前 `any-key` 会把鼠标按钮也算进去。
|
|
|
|
|
|
|
|
|
|
|
|
- `IsAnyKeyDown()`
|
|
|
|
|
|
- 扫描 `m_keyDown` 和 `m_mouseButtonDown`
|
|
|
|
|
|
- `IsAnyKeyPressed()`
|
|
|
|
|
|
- 扫描 `m_keyDownThisFrame` 和 `m_mouseButtonDownThisFrame`
|
|
|
|
|
|
|
|
|
|
|
|
它目前不包含:
|
|
|
|
|
|
|
|
|
|
|
|
- 触摸
|
|
|
|
|
|
- 滚轮
|
|
|
|
|
|
- 文本输入
|
|
|
|
|
|
- 摇杆或手柄输入
|
|
|
|
|
|
|
|
|
|
|
|
同时,`IsAnyKeyPressed()` 也继承键盘 repeat 语义。只要平台后端在某一帧再次调用 `ProcessKeyDown()`,它就可能再次成立。
|
|
|
|
|
|
|
|
|
|
|
|
## 默认映射与当前落地程度
|
|
|
|
|
|
|
|
|
|
|
|
初始化后默认注册这些逻辑项:
|
|
|
|
|
|
|
|
|
|
|
|
- 轴
|
|
|
|
|
|
- `Horizontal`: `D / A`
|
|
|
|
|
|
- `Vertical`: `W / S`
|
|
|
|
|
|
- `Mouse X`: `None / None`
|
|
|
|
|
|
- `Mouse Y`: `None / None`
|
|
|
|
|
|
- 按钮
|
|
|
|
|
|
- `Jump`: `Space`
|
|
|
|
|
|
- `Fire1`: `LeftCtrl`
|
|
|
|
|
|
- `Fire2`: `LeftAlt`
|
|
|
|
|
|
- `Fire3`: `LeftShift`
|
|
|
|
|
|
|
|
|
|
|
|
这说明当前默认配置的实际可用性是分层的:
|
|
|
|
|
|
|
|
|
|
|
|
- `Horizontal` / `Vertical` 可以直接驱动基础移动逻辑
|
|
|
|
|
|
- `Jump` / `Fire1-3` 可以直接用于具名按钮查询
|
|
|
|
|
|
- `Mouse X` / `Mouse Y` 当前更像预留命名位,而不是已经接通的鼠标轴
|
|
|
|
|
|
|
|
|
|
|
|
## 平台桥接为什么值得保留独立层
|
|
|
|
|
|
|
|
|
|
|
|
当前 Windows 路径里,`WindowsInputModule::HandleMessage()` 会把 Win32 消息翻译成 `InputManager::Process*()` 调用。
|
|
|
|
|
|
|
|
|
|
|
|
这种分层设计的直接收益是:
|
|
|
|
|
|
|
|
|
|
|
|
- 游戏层不用理解平台消息细节
|
|
|
|
|
|
- 测试可以绕开平台窗口系统,直接驱动 `InputManager::Process*()` 来验证行为
|
|
|
|
|
|
- 将来替换平台层时,输入消费方可以尽量保持不变
|
|
|
|
|
|
|
|
|
|
|
|
这也是为什么现有测试大多直接命中 `InputManager`,而不是依赖完整窗口消息环境。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
|
|
|
|
|
## 当前版本最该知道的限制
|
|
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- `KeyCode` 底层值当前存在重复项,部分按键会共享状态槽。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
- Windows 路径当前不能准确区分左右 Ctrl / Alt / Shift。
|
2026-04-08 16:07:03 +08:00
|
|
|
|
- 触摸与摇杆接口大多还是预留位。
|
|
|
|
|
|
- 事件回调同步执行,不是异步消息队列。
|
|
|
|
|
|
- `Shutdown()` 会清空状态和映射,但不会清空事件监听器。
|
|
|
|
|
|
- `ClearAxes()` 当前会同时清空轴和按钮映射。
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
## 推荐接入方式
|
2026-03-26 17:39:53 +08:00
|
|
|
|
|
|
|
|
|
|
初始化阶段:
|
|
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
|
using namespace XCEngine::Input;
|
|
|
|
|
|
|
|
|
|
|
|
InputManager::Get().Initialize(windowHandle);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
平台消息阶段:
|
|
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
|
windowsInputModule.HandleMessage(hwnd, msg, wParam, lParam);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
游戏逻辑阶段:
|
|
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
|
if (InputManager::Get().GetButton("Jump")) {
|
2026-04-08 16:07:03 +08:00
|
|
|
|
// 持续按住态
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (InputManager::Get().GetButtonDown("Jump")) {
|
|
|
|
|
|
// 本帧按下边沿
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (InputManager::Get().GetButtonUp("Jump")) {
|
|
|
|
|
|
// 本帧释放边沿
|
2026-03-26 17:39:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
帧边界阶段:
|
|
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
|
InputManager::Get().Update(deltaTime);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-08 16:07:03 +08:00
|
|
|
|
如果你把 `Update()` 放错位置,或者在同一帧里调用多次,最先出问题的通常就是 `Pressed` / `Released` / `MouseDelta` 这些瞬时语义。
|
|
|
|
|
|
|
2026-03-26 17:39:53 +08:00
|
|
|
|
## 相关 API
|
|
|
|
|
|
|
|
|
|
|
|
- [Input](../../XCEngine/Input/Input.md)
|
|
|
|
|
|
- [InputManager](../../XCEngine/Input/InputManager/InputManager.md)
|
|
|
|
|
|
- [InputModule](../../XCEngine/Input/InputModule/InputModule.md)
|
|
|
|
|
|
- [InputTypes](../../XCEngine/Input/InputTypes/InputTypes.md)
|