源设计起源 · 为什么要做这个模块
一句话
一套框架要同时伺候键盘 / 鼠标 / 触屏 / 手柄,还要能改键、能被弹窗挡住、手感参数还得可调 —— 输入系统就是把这些"杂事"统一收口,业务只用动作名读输入。
- 先说清边界:它是通用输入,不是帧同步输入:帧同步那套要求输入变成可序列化的帧命令、精度量化成整数、按帧号算节拍,还要 seq + 确认 + 补发;通用项目没有那个同步层,本地输入也没有丢包,照搬只会凭空多出间接层和纯成本。可复用的是"机制"(设备抽象、采集与判定分离、零分配、手感参数化),不可复用的是"为确定性付出的代价"。
- 没有它,每处输入都要自己处理一堆杂事:改键、屏蔽、缓冲、多指抢占、切后台复位……全都散在业务里。本模块把它们收进一个内核,业务只认动作名、不认键位(改键 / 屏蔽 / 缓冲都在同一个抽象上做,业务代码里没有键位字面量)。
- 参考实现缺什么,这里就补什么:键盘 / 鼠标支持、多点触控的 id 出口、多种手势 + 八向滑动、输入缓冲窗口、失焦 / 切后台复位、可精确到指针的屏蔽栈、可断言的内核、绑定文本往返 + 冲突检测。
- 为什么坚持纯 C# 内核 + 热路径零分配:死区、双击窗口、滑动阈值、多指抢占这些"最容易写错"的手感参数必须能脱机断言;而输入是逐帧热路径,零分配是硬指标,所以就地 try/catch、不传 lambda(否则每次都要分配一个闭包)。
本篇只讲为什么;怎么用见同目录《使用说明》。
0三句话速览(完整用法见《使用说明》)
① 绑定 RevInput.Bind("Jump", RevKey.Space, RevKey.JoystickButton0); // 业务只认动作名,不认键位 ② 订阅 RevInput.OnPressed("Jump", Jump, owner: this); // 或 AddListener(this) 一次收全部 ③ 退订 RevInput.OffAllOf(this); // 事件 + 屏蔽一起清
就这三步。剩下的都是这三步背后的设计取舍。
1定位:通用输入系统,不是帧同步输入
先说清边界,否则后面每条设计都会被问"为什么不按帧同步那套来"。
| 维度 | 为帧同步设计的输入方案 | 本框架(通用) |
|---|---|---|
| 输入去哪 | 必须变成可序列化的帧命令(整数/定点载荷),由服务器排序分发 | 就地给业务读(Pressed/Held/Axis),不需要网络层 |
| 精度 | 方向必须量化成整数角度(追求确定性) | 保留模拟量(-1..1 浮点),可做速度/幅度 |
| 时间基准 | 一切按帧号(且常以 30fps 折算)算节拍 | 时间以参数进来:deltaTime 算持续、真实时间算窗口 |
| 可靠性 | 连续量要 seq + 确认 + 补发 + 补停 | 不需要(本地输入没有丢包) |
| 设备覆盖 | 触屏摇杆 + 手柄(PC 带宏) | 键盘 / 鼠标 / 触屏 / 手柄 全都要 |
| 手势 | 往往被压成"一串整数方向" | 7 种手势:点击 / 双击 / 长按 / 拖动 / 滑动(八向+速度)/ 捏合 / 旋转 |
| 弹窗挡输入 | 通常靠"切模式"粗粒度互斥 | 可精确到指针的屏蔽栈 + 作用域 |
结论
可复用的是"机制"(设备抽象、来源仲裁、采集与判定分离、零分配、手感参数化),不可复用的是"为确定性付出的代价"(帧命令、整数载荷、帧号节拍、可靠性通道)。本模块只搬前者。
2分层与文件地图
RevInput/
├── Core/ 纯 C#(零引擎依赖,可脱机断言)
│ ├── RevInputDefines 词汇表:设备类型 / 相位 / 手势 / 屏蔽 / 错误码 / 全部默认值
│ ├── RevInputCodes 键位码表(名字对齐 Unity KeyCode)+ RevKeyMask(零分配键位掩码)
│ ├── RevInputSnapshot 一帧快照:键位三份掩码 + 鼠标 + 指针数组(复用实例,零分配)
│ ├── RevInputBinding 一个动作的绑定(键位 / 鼠标键 / 轴)+ 预计算掩码
│ ├── RevInputActionTable 绑定表 + 全部动作状态(三态 / 轴 / 连发 / 缓冲 / 文本往返 / 冲突检测)
│ ├── RevGestureRecognizer 7 种手势的识别状态机(阈值全可调)
│ ├── RevInputBlock 屏蔽句柄
│ └── RevInputListener 事件驱动接入面:监听者接口 + 空实现基类(登记一次,输入推给你)
├── Facade/RevInput 唯一入口(业务只读这个)
├── Implementation/
│ ├── RevInputCore 内核:一帧的流水线 + 屏蔽栈 + 事件派发 + 失败上报
│ └── RevInputLog 日志出口(正常路径一条不打)
└── Support/ 引擎适配(全模块唯一碰 UnityEngine 的地方)
├── RevInputDriver 隐藏宿主:每帧采集 + 失焦/切后台自动复位
├── RevInputUnityDevice 默认设备:键鼠 / 触屏 / 手柄 → 快照
├── RevInputUnityHooks 进 Play 复位 / 日志接线 / UI 命中判定
└── RevInputScope 作用域:出块自动解除屏蔽 + 退订事件
一句验收口径
Core/ 与 Implementation/ 里没有任何 UnityEngine / KeyCode / Time. / Input. / #if UNITY_* ——
所以能把这些文件直接链接进普通 .NET 工程跑断言(见第 6 节)。
3一帧到底做了什么
| # | 步骤 | 在哪 | 关键点 |
|---|---|---|---|
| 1 | 快照 Reset()(保留容量) | RevInputCore.Tick | 定长掩码 + 复用数组 → 零分配 |
| 2 | 设备 Poll(snapshot) 写原始输入 | RevInputUnityDevice | 设备坏了只隔离它自己(本帧按"无输入"处理) |
| 3 | 判相位 + 算轴(一次遍历算完所有动作) | RevInputActionTable.Update | 位运算命中判定;屏蔽期间动作相位与轴一并按"无输入"处理,但仍记"按下时间"(缓冲不丢) |
| 4 | 手势识别(指针采样 → 7 种手势) | RevGestureRecognizer.Update | 屏蔽期间只跟踪不产出,且不秋后算账 |
| 5 | 派发动作事件(按下/抬起) | RevInputCore | 就地 try/catch 隔离;不用 lambda(零闭包分配) |
| 6 | 派发手势事件 | RevInputCore | 同上 |
| 7 | 派发连发事件(按住按节拍) | RevInputCore | 只有配了 SetRepeat 的动作才有;逐动作查表命中才派 |
| 8 | 派发轴事件(只在数值变化时) | RevInputCore | 每个轴记"上次投递值";订阅后第一帧先给一次当前值(方便初始化移动/UI) |
| 9 | 投给监听者(按下 / 抬起 / 连发) | RevInputCore | 按动作表遍历(不依赖有没有人订阅委托:只注册监听者也能收到);逐条隔离 |
| 10 | 业务查询(RevInput.Pressed(...)) | Facade/RevInput | 全是读结果,不重算 |
顺序上有三个刻意安排:
- 相位先算、事件后派:事件回调里读到的状态和业务在同一帧读到的一致(不会"回调里读到的是上一帧");
- 屏蔽判定在采集之后、判定之前:被屏蔽时设备照样采集(指针位置可读),但动作相位与手势按"无输入"处理;
- 事件驱动与轮询同帧同源:监听者、
OnPressed这类委托、轮询 API 读的是同一份状态;而 ⑦⑧⑨ 在"没有订阅者 / 没有监听者"时提前 return,零开销(不影响逐帧性能)。
4十一个关键设计决策
| # | 决定 | 为什么 | 代价 |
|---|---|---|---|
| 1 | 键位名与 Unity KeyCode 一一对齐(RevKey.Space …) | 适配层用 Enum.TryParse 建映射表,不手抄上百行数值(抄错一位就是"跳跃绑到退出"的线上事故) | 键位名不能随意改(已发布的改键存档按名字存) |
| 2 | 动作名而不是键位 | 改键、屏蔽、缓冲全部在一个抽象上做;业务代码里没有键位字面量 | 启动时要绑一次(或读存档) |
| 3 | 键位掩码 + "绑定并集"轮询 | 每帧只测"绑定用到的键 ∪ 上一帧按住的键"(十几个),而不是无脑测一百多个键 | 键位上限 256(掩码 4×64 位),够用且 MaskFits 会自检 |
| 4 | 纯 C# 内核 + 一个采集委托 | 手感参数(死区、双击窗口、滑动阈值、多指抢占)必须能脱机断言;引擎适配层只通过一个采集委托接进来(内核不认识任何引擎类型,也不认识"输入从哪来") | 内核不能用 Vector2(返回自定结构)、时间要当参数传 |
| 5 | 相位只有 4 种(Down/Held/Up/None) | 少即是多:"双击""长按""连发"都是派生读数(PressedBuffered / Repeat / 手势),不是新相位 | 业务要知道去哪儿找这些派生读数 |
| 6 | 输入缓冲窗口(PressedBuffered(action, 0.15s)) | 动作游戏标配的"提前按也算"——落到地面前的跳跃不再白按 | 业务要显式选"用缓冲还是用严格按下" |
| 7 | 屏蔽精确到指针 + 屏蔽期间仍记缓冲 | ① 弹窗挡世界输入但不能挡 ESC ② 屏蔽解除瞬间,缓冲窗口里的输入立刻生效(否则手感会莫名迟钝) | 屏蔽栈要留 owner(用于一行清理) |
| 8 | 手势八向 + 带速度;双指不做秋后算账 | 斜向滑动是真实需求("左上闪避");而被屏蔽/双指期间开始的手势,抬手时不补发 Tap | 手势阈值需要按项目调(默认值只是起点) |
| 9 | 热路径零分配(就地问 try/catch,不传 lambda) | Guard(() => …) 这类写法每次都要分配一个闭包;输入是逐帧热路径,600 帧零分配是硬指标 | 异常上下文只带动作名(不带拼好的句子) |
| 10 | 失败给原因码,不打日志 | 与框架其它模块一致的契约:正常路径一条日志不打;失败可订阅 RevInput.Failed,要诊断开 VerboseLog | 排查要主动订阅或开开关(不能指望控制台自己冒日志) |
| 11 | 事件驱动接入面(监听者 + 轴 / 连发事件) | 业务不该为了收输入而每帧轮询:AddListener 登记一次,按下 / 抬起 / 连发 / 轴变化 / 手势由框架推过来(和另一套输入体系的用法一致);轴只在数值变化时推,且订阅后先给一次当前值 | 监听者按 owner 登记、销毁时一行清理;"事件驱动"与"轮询"两套写法并存(文档要把各自适用场景讲清) |
5与参考实现的对照
参考实现是一套为帧同步设计的输入体系(分层清晰、手感观测做得很扎实)。这里逐条说明"借鉴了什么 / 为什么没搬 / 补了什么"。
5.1 借鉴过来的机制
| 机制 | 在本框架里落成 |
|---|---|
| 采集与业务分离(组件只报数据,判定在别处) | 采集层写快照、内核判定 —— 两层之间只有一个采集委托 |
| 手势识别的参数化(阈值可调、可迭代) | RevGestureRecognizer 的 8 个阈值字段,全部可运行时改 |
| 连续量要有"来源标记"避免多来源打架 | 本模块简化成"屏蔽栈 + 设备替换"(本地输入没有"触屏 vs 手柄同时给同一根轴"的带宽问题) |
| 零 GC 纪律 | 掩码 / 复用数组 / 就地 try-catch / 无 LINQ |
| "隐藏复位"防卡死(界面隐藏时复位摇杆) | 升级为失焦 / 切后台自动复位 + ResetAll 手动兜底 |
| 观测埋点要带上下文(不是裸事件) | RevGestureEvent 自带位置/起点/位移/速度/时长;动作状态自带 HeldSeconds / LastPressedFrame |
5.2 刻意没有搬过来的
| 帧同步的做法 | 为什么不要 |
|---|---|
输入必须变成 FrameCommand(命令工厂 + ID 特性 + Send) | 通用项目没有那个同步层;凭空引入间接层只会让每处输入都要绕一圈 |
| 载荷禁止浮点(只能短整型/定点) | 会丢掉模拟量:速度、幅度、镜头相对位移、鼠标 delta 都需要浮点 |
| 以帧号(30fps 折算)为节拍(6 帧补发 / 15 帧补停 / 30 帧心跳) | 通用项目帧率自由;这套节拍在这里只会制造"随时间漂移的阈值" |
| 可靠性通道(seq + 确认 + 补发) | 本地输入没有丢包,可靠性是纯成本 |
| 用"模式状态机 + 一堆 bool 子开关"表达玩法差异 | 会布尔组合爆炸;本模块用"绑定 + 屏蔽 + 手势阈值"表达差异 |
| 采集层绑在具体 UI 框架组件上 | 会让输入模块依赖 UI 系统;本模块的采集层与内核之间只有一个采集委托 |
5.3 参考实现缺、本模块补上的(这类最多)
| 补的东西 | 解决的问题 |
|---|---|
| 键盘 / 鼠标支持 | 通用项目的第一需求 |
| 多点触控的 id 出口 | 只报"位置"的多点等于没有多点:屏蔽单指、双指手势都要 id |
| 7 种手势 + 八向滑动 | 触屏项目的日常需求 |
| 输入缓冲窗口 | 手感问题里最高频的一类 |
| 失焦 / 切后台复位 | "切回来角色一直跑"的解药 |
| 可精确到指针的屏蔽栈 | 弹窗叠弹窗、教程遮罩、拖镜头的手指互不干扰 |
| 可断言的内核 | 死区 / 双击窗口 / 滑动阈值 / 多指抢占这些最容易写错的地方,必须能脱机测 |
| 绑定文本往返 + 冲突检测 | 玩家改键要能存、要能查冲突 |
6验收(都是跑出来的)
| 项 | 结果 |
|---|---|
Revolution.Runtime 编译 | 0 错误 0 警告(含新模块 15 个文件 / 3,643 行) |
Revolution.Editor 编译 | 0 错误(4 条警告是 Unity 测试包自带的旧警告) |
| 纯 C# 依赖检查 | Core/ Implementation/ Facade/ 里零引擎标识符;#if UNITY_* 共 0 处 |
| 工程外行为断言 | 86 / 86 通过(含事件驱动接入 18 条:监听者投递 / 轴只在变化时推 / 连发 / owner 一行清理 / 异常隔离;临时工程链接真实源码 + 一段脚本直接写快照喂帧,跑完即删) |
| 断言覆盖 | 掩码 / 绑定 / 三态 / 轴与死区 / 缓冲窗口 / 连发节拍 / 文本往返无损 / 坏存档拒绝且不落地 / 冲突检测 / 鼠标 / 八向方向 / 7 种手势 / 双指不秋后算账 / 屏蔽精确到指针 / 屏蔽期间保留缓冲 / 作用域出块自动解除 / 事件派发与 owner 清理 / 复位 / 失败原因码 / 600 帧零分配 |
| 断言抓到的真 bug | ① RevInputScope.Dispose 先置位再调用导致清理被跳过(出块后屏蔽没解除 ✗)② PointerCount 没过滤被屏蔽的手指(与 Pointer(index) 下标不一致 ✗)—— 都在提交前修掉 |
7本期没做的(可扩展)
| 项 | 说明 |
|---|---|
| 虚拟摇杆 UI 组件 | 目前给的是"轴 + 手势 + 屏蔽"的原语;摇杆外观/跟手/防误触属于 UI 层,建议在 UI 系统里实现(用本模块的指针与屏蔽接口) |
| 手柄摇杆的默认轴名映射 | 现在要走 BindNamedAxis("MoveX", "Horizontal")(引擎默认自带);直接内置会在没配轴的工程里刷 Warn |
| 改键 UI | 绑定文本与冲突检测已经就绪(SaveBindings / LoadBindings / FindConflicts),UI 归业务 |
| Input System 包适配 | 换包只需改 RevInputUnityDevice(采集层)一个文件,内核完全不用动 |