键盘侠玩家最爱问的一句话:"能不能加个快捷键?"听了二十年的问题,答案就躺在 logic/UserInputController.lua 的 compareKeyCode 函数里。这个三十行的小函数管着全游戏"ctrl+b 开背包、shift+tab 切目标"的每一次命中判定,今天把它连同整套按键系统讲透——你会看到,一个键是怎么从物理敲击变成游戏行为的。
先看这个控制器的出场方式,它是个单例:
function UserInputController:Inst()
if not UserInputController.instance then
UserInputController.instance = UserInputController.new()
global.Facade:registerMediator(UserInputController.instance)
end
return UserInputController.instance
end
键盘天然是全局唯一资源,单例是它的宿命。Inst() 惰性创建、注册进 Facade,既保证全世界只有一个键盘仲裁者,又不耽误它享受消息中枢的基础设施。destory 里那句 UserInputController.instance = nil 也别漏看——单例的销毁必须把静态引用一起清掉,否则下一次 Inst 拿到的是个已注销 Mediator 的僵尸对象,表现是"快捷键全面失灵但没有任何报错"。单例的生死要成对,这条纪律在通知机制那篇的 Mediator 生命周期里讲过,这里是它在单例身上的具体化。
组合键的灵魂是修饰键(Ctrl/Shift/Alt),引擎用三个成员变量盯着它们:
function UserInputController:ctor()
UserInputController.super.ctor(self, self.NAME)
self._keyboardListener = nil
self._keyboardEvents = {} -- 绑定表:键数组 → 处理函数
self._keyboardAble = true -- 总开关
self._isPressedShift = false -- 三个修饰键的实时状态
self._isPressedCtrl = false
self._isPressedAlt = false
self._cursorPosition = {x=0,y=0} -- 光标位置顺手也记着
self._touching = false -- 触摸状态
end
Ctrl 按下置 true,松开置 false,三个布尔值就是键盘的"当前姿势"。为什么不用键盘事件里自带的 ctrlKey 属性,非要自己记?因为组合匹配发生的时机是功能键按下的一瞬,那时修饰键早已按下、它们的 keydown 事件早就过去了——事件对象里查不到"现在还按着没有",必须自己记账。这个设计决定决定了后面所有代码的形状:修饰键是状态,功能键是事件,状态加事件拼出完整的组合。
构造函数里那两个顺手的字段也别放过:_cursorPosition 记光标位置、_touching 记触摸态。输入控制器是个"全知者"——鼠标在哪、键盘按着什么、屏幕被摸没摸,它全都记着。这不是设计洁癖,是仲裁的需要:判断"这次点击该给谁"的前提是知道"现在指针在哪、键盘什么状态"。输入仲裁者必须全知,全知就必须把所有输入源的状态集中记在自己身上——散落在各节点的输入状态是无法仲裁的,这是它坚持记账的深层原因。
绑定表 _keyboardEvents 的键是数组(如 {"ctrl","b"}),值是处理函数。玩家敲下 ctrl+b 的瞬间,系统把"当前修饰键状态 + 这次的功能键"拼成一个候选组合,拿去绑定表里查——查得上就执行,查不上就当无事发生。
核心比对函数带一个开关参数,走两条完全不同的路:
local function compareKeyCode(k1, k2, checkFullSort)
if checkFullSort then
-- 严格模式:先比长度,再做"你的每个键我都有"的逐键查
if #k1 == #k2 then
for i = 1, #k1 do
local keyCode = k1[i]
local isEqual = false
for j = 1, #k2 do
if keyCode == k2[j] then
isEqual = true
break
end
end
if not isEqual then
return false
end
end
return true
else
return false
end
end
-- 拼接模式:直接拼字符串比
return (table.concat(k1, "+") == table.concat(k2, "+"))
end
长度前置检查那句 if #k1 == #k2 是严格模式的快刀:长度都不一样,成员必然不齐,内层循环一次都不用跑。这层"廉价的先决条件先行"是所有比对函数的第一优化位——先排除十拿九稳的不可能,再进精细比对。顺序感对了,性能就顺了。
两条路各有各的命门。拼接模式把 {"ctrl","b"} 拼成 "ctrl+b" 直接比字符串——快是快,但顺序敏感:玩家先按 b 再按住 ctrl 时,系统拼出 "b+ctrl",跟绑定表的 "ctrl+b" 对不上,组合失灵。严格模式先比长度再逐个查等,两个数组"元素相同即相等",顺序无关,先 ctrl 后 b、先 b 后 ctrl 都命中。
严格模式的内层结构也值得品:外层遍历 k1 的每个键,内层在 k2 里逐个找等价项,找到就 break,一个不齐就整单否决。这是标准的"集合相等"判定——两个数组当集合看,不看顺序只看成员。内层的 break 是给高频路径省的:键盘事件一秒十几次,每次全表比对,早退一行就省一行。查等不排序、不建哈希,直接双层循环——三个键的组合最多九次比较,任何"聪明"的结构都赢不了这个朴素写法。数据规模在个位数时,朴素算法就是最优算法,这话在队列篇说过,在 compareKeyCode 里再次应验。
实战里选哪条?结论很干脆:组合键必须走严格模式。玩家按组合键的手指顺序根本不可控——右手 ctrl 左手 b、左手 ctrl 右手 b,先后的排列组合有六种,拼接模式只能命中其中一种,剩下五种全是"失灵"。当年有服主反馈"ctrl+b 十次有三四次打不开背包",就是拼接模式在作祟,切成严格模式当天清零。拼接模式留着的用途是"整串对比"的调试场景,业务路径别碰。
换个角度想这个问题:拼接模式其实在比较"按键的顺序序列",严格模式在比较"按键的集合"。输入系统的语义是集合(哪些键处于按下状态),不是序列(按键的历史顺序)——序列里藏着时机,时机是双击检测的领地(0.3 秒窗口那套),不是组合键的。语义选对了,比对函数自然就选对了:状态比集合,历史比序列。这半页纸的哲学,能帮你在十几种输入判定需求里各找各的函数。
flowchart TD
A[玩家按下功能键] --> B[读取修饰键三布尔]
B --> C[拼出当前组合数组]
C --> D{compareKeyCode 模式?}
D -->|严格 checkFullSort| E[长度相等? 逐键查等]
D -->|拼接| F[table.concat 字符串比对]
E --> G{绑定表命中?}
F --> G
G -->|是| H[执行绑定函数]
G -->|否| I[静默忽略]
H --> J[_keyboardAble=true 才有效]
比对之前还有道闸:_keyboardAble。聊天框打字的时候,你敲的 b 是文字不是挥刀——输入法激活或聊天框聚焦时,这个开关置 false,所有游戏快捷键静音。这类"键盘所有权"的仲裁是输入系统最容易翻车的地方:某服做聊天框二开时忘了关这个开关,玩家在聊天框里打"bs"想骂人,结果切了两次攻击目标,骂人的话变成了 bs 加两次挥刀,场面一度十分哲学。
仲裁的正确姿势是所有权唯一:同一时刻键盘只属于一个主人。聊天框要输入,先申请所有权(_keyboardAble=false),失焦归还。引擎在登录、选角、进世界三个阶段各调一次 ResetKeyboard + 初始化键位表,本质上也是所有权交接:不同游戏阶段可用的快捷键不同,换阶段就换一张绑定表。你做全屏界面(摆摊、副本进度页),同样该走"申请-归还"流程,别用 if 判断层层加特判——特判加到第十层的时候,没人记得第一层为什么存在。
键位表按阶段初始化还有个安全考量藏在 InitKeyCodeDebug 这个函数名里:调试快捷键和玩家快捷键分表登记,调试表只在构建环境注册。某服曾把调试键打包进正式服,玩家无意按出"全屏穿墙"的调试组合,截图传遍论坛。调试功能的键盘入口和调试日志一样,属于"发布前必须拔掉的线头",分表登记让拔线变成删一行注册的事。
下面这个演示把按键系统搬上了台面:点一下画布聚焦,直接敲键盘,修饰键的三个布尔实时点亮、绑定表逐条比对命中;勾掉"严格比对"切到拼接模式,故意先按 b 再按住 ctrl,亲眼看严格模式命中而拼接模式失灵——三十年前玩家手抖的谜题,答案就在这两个模式的差别里:
skill-hotkey-combo-1003d
某服要做"自定义快捷键":玩家自己把"打开背包"绑到任意键位。这套需求踩在 compareKeyCode 肩膀上,落地四个要点,工时三天。
第一,绑定数据结构沿用键数组。玩家配置存 "ctrl+b" 字符串,加载时 split 成数组喂给绑定表,比对逻辑零改动。第二,冲突检测:新键位与既有绑定的数组做严格比对,命中即提示冲突——比对函数复用,一码两吃。第三,修饰键状态恢复:切窗回来时三个布尔可能残留 true(松键的 keyup 发生在别的窗口),窗口失焦事件里强制清零,否则"切出去回来看见角色自己在抽风"。第四,按键宏检测:同一秒内同一组合触发超过 8 次判宏,静默吞掉第 9 次——服务端有频率检测,客户端先礼貌拦截,省得玩家被误封。
第一条的存储格式选择也有一句忠告:玩家配置文件里存拼接串 "ctrl+b" 而不是数组,是因为配置文件要人能读、手能改——客服远程指导玩家改配置的场景真实存在。运行时用数组、存储时用字符串,加载和保存两端各做一次转换,两边都用最顺手的形式。数据格式的"两副面孔"原则,和 compareKeyCode 自己的两副面孔是同一个思想:格式跟着读者走,别让读者迁就格式。
四条里第三条是血泪:上线首周"按键失灵"工单 27 条,全是切窗后修饰键卡住。Alt+Tab 切窗口是这个游戏的基本操作,Alt 的 keyup 永远丢在别的进程里——窗口失焦清空全部按键状态,这一行代码写进输入系统的第一课,比 compareKeyCode 本身还重要。
配置系统的玩家侧还有个体验细节值得抄:按下新键位时,界面实时高亮"当前按下的组合",玩家想要的键不用描述、直接演示。实现就是把 compareKeyCode 的候选组合展示出来——候选组合是现成的,展示层只是把它画出来。输入系统调试面板、按键冲突提示、宏检测设置,全都长在这同一个数据源上。基础层把状态记全了,上层的每个功能都是画图题。
问:为什么不用 keydown 事件自带的 ctrlKey 属性比对?
事件属性只在事件发生那一刻有效,而"按住 ctrl 再按 b"需要知道 b 按下时 ctrl 还在不在——三个布尔是跨事件的记忆体,事件属性给不了这个跨度。自记状态还有个好处:失焦清零有明确的清理对象。
问:玩家按住 ctrl 连续按三次 b,触发几次?
按引擎的键位表结构,每次功能键 keydown 都触发一次绑定,三次就是三次——这就是连按宏检测存在的意义。要"按住不连发",得在功能键层加"按住中不重复触发"的本地去重,属于业务层的选择,输入层只管如实上报。
问:全屏界面下快捷键要不要保留?
分键。全局型(截图、音量)保留,业务型(开背包、切目标)必须随 _keyboardAble 一起静音。判断标准一条:这个键的操作目标如果不在当前界面里还成立吗?不成立就该静音——摆摊界面里切攻击目标,打的是空气。
问:一个组合绑多个功能会怎样?
按绑定表的遍历顺序命中第一个就 break,后面的永远轮不上——这是"先注册先得"的又一处体现。配置系统要做冲突检测就是这个原因:引擎不会报错,只会沉默地执行排在前面那个,玩家以为绑失败了,其实是被顶了。沉默的失败比报错更伤人,凡是可能被顶的场景都要把冲突摆到台面上。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…