背包是每个玩家每天开二十次的界面,也是全引擎交互密度最高的地方:单击看属性、长按弹菜单、双击直接用、拖拽换位置——一个小小的格子上叠了四种手势,每种还都不能误伤邻居。996 引擎的答案在 util/GoodsItem.lua,一个继承自 CacheWidget 的格子控件。今天把它的触摸状态机摊开,你会发现一个"好用的背包"背后有多少较劲。
先说继承链,这决定了格子的出身。GoodsItem 继承 CacheWidget——缓存控件基类,和 WidgetCacheManager 那篇的控件池是配套工程:背包 80 个格子开合一次就是 160 次控件的生死,没有池化早就卡成幻灯片。格子的手势逻辑和池化出身写在同一个类里,读代码时两套关注点要分开看:池化决定它"从哪来",状态机决定它"怎么响应"。继承 CacheWidget 还带来一个隐含义务——格子在池子里存着时必须洗干净,上一件装备的名字、数量、灰显状态不能带给下一件,这条在控件池篇讲过的纪律,在背包场景是日常执行的。
文件开头就把触摸事件的家谱摆出来:
-- 点击事件
local kTouchAll = 0
local kTouchPress = 1 -- 长按
local kTouchClick = 2 -- 点击
local kTouchWidget = 3 -- widget touch
local kTouchBegan = 4
local kTouchMove = 5
local kTouchEnd = 6
local kDoubleTime = global.MMO.CLICK_DOUBLE_TIME -- 双击窗口,全局常量
local itemMoveState = {
begin = 1,
moving = 2,
end_move = 3, -- 拖拽的三个阶段
}
编号从 0 起头的排法也有讲究:kTouchAll = 0 是个"全部"的哨兵值,注册回调时用它表示"我对所有触摸事件都感兴趣"。哨兵值占零号位,正常事件从一号排开——和队列篇 _index 从零起步、A* 的"0 可走"一个套路:零号留给特殊语义,正数留给正常业务。读任何引擎的枚举表,先看零号是谁,多半就懂了这张表的工作方式。
这组常量就是格子的"手势语言表":点击、长按、双击、拖拽,四个动作各领一个编号。注意双击窗口 kDoubleTime 是从全局 MMO 常量取的,不是文件内写死——背包双击和聊天双击必须同一节奏,玩家才不会觉得"有的格子灵敏有的迟钝"。全局交互常量统一供数,这是手感一致性的第一保障,跟富文本那篇的默认字号收口是同一个道理。
构造函数里那一排字段就是手势状态机的全部家当:_pressCallback、_clickCallback、_doubleEvent 各管一种回调,_lastClickTime 给双击窗口计时,_moveItem 管拖拽许可,_greyStatus 管灰显,_noSwallow 管事件吞噬开关。一个格子的字段数就是它的交互能力清单——要看一个控件支持什么手势,数它的成员变量比看文档快。交互密度高的控件,字段表就是它的自我介绍,这个读代码的路数在 UI 层百试百灵。
拖拽三态 begin/moving/end_move 单独一组,因为拖拽的生命周期和其他手势完全不同——它是持续的,不是瞬时的。瞬时手势用编号,持续手势用阶段,两组常量的分法就藏着这个分类学。
格子接到一次按下,到松手之间要当三回裁判。第一回合判"拖还是不拖":
self._moveItem = data.moveItem -- 这个格子允不允许拖(绑定物不行)
...
self._moveCancelCallBack = nil -- 拖拽取消回调(物品飞回原格)
初始化里这两个字段是拖拽的准入证:moveItem 为假的格子(任务绑定的关键道具)从一开始就不参与拖拽判定,move 超阈值也不触发。权限判定放在状态机之前,比事后拦截干净得多——不允许的事连状态都别进。
数据装载那段也顺带看一眼,格子的数据驱动模式很典型:
function GoodsItem:InitData(data)
if tonumber(data) then
local index = data
data = {}
data.index = index -- 传个数字进来也能用:自动包装成表
end
local ItemConfigProxy = global.Facade:retrieveProxy(global.ProxyTable.ItemConfigProxy)
data = data or {}
self._data = data
self._itemData = data.itemData or ItemConfigProxy:GetItemDataByIndex(data.index)
...
end
调用方传一个格子序号也行、传完整物品数据也行,InitData 两条路都接:数字就包装成表、查不到 itemData 就去 ItemConfigProxy 按序号现查。入口宽容、出口严谨——怎么喂都吃,但内部数据结构永远规整。这种入口自适应的写法让控件的调用方极其轻快,代价是函数里多做一次类型判断,划算至极。
第二回合判"长按还是点击"。按住不动,计时器走满长按时长(引擎用调度器盯)就触发 kTouchPress 弹操作菜单;中途位移超阈值,长按计时作废转拖拽;快速松手走 kTouchClick。三个手势共享同一次按下的时间轴,判定的优先级是:位移先淘汰(拖拽),时长再淘汰(长按),活到最后的才是点击。淘汰赛的顺序不能乱——先判点击的话,每次长按都会先弹一次 Tips,菜单和提示框叠罗汉的场面就是这么来的。
第三回合判"单击还是双击":单击后不立刻执行,先记住这个格子,双击窗口(kDoubleTime)内同格再点一次才升级成双击。这和鼠标事件那篇的 0.3 秒双击窗口是同一套悬而未决的艺术,只是这次挂在格子上。代价是单击有约 300 毫秒的延迟确认,背包选择"单击看详情、双击直接用"的服务都会接受这个延迟——双击存在的地方,单击注定要等一个窗口期,这是手势设计的基本物理。
flowchart TD
A[手指按下格子] --> B{moveItem 允许拖?}
B -->|允许| C[begin: 记录起点]
B -->|禁止| D[跳过拖拽判定]
C --> E{位移 > 阈值?}
E -->|是| F[moving: 进入拖拽, 长按/点击作废]
D --> G{按住时长 > 长按阈值?}
G -->|是| H[kTouchPress: 弹操作菜单]
E -->|否| G
G -->|否, 快速松手| I{双击窗口内同格二连?}
I -->|是| J[双击: 直接使用物品]
I -->|否| K[kTouchClick: 弹Tips, 留窗口等二连]
F --> L[end_move: 落点在格子上→交换 / 格子外→飞回原格]
拖拽最容易被忽视的是取消路径。物品拖到一半,落点不在任何格子上(格子外、非法目标、被占用),怎么办?引擎给的答案写在 _moveCancelCallBack 这个字段名里——取消回调:物品做一个飞回原格的动画,一切如初。这个设计对玩家心理的照顾极其到位:拖错了不惩罚,松手即反悔,操作零焦虑。
取消回调为什么设计成"可注入的字段"而不是写死的行为?因为取消后的去向是业务问题:背包内拖拽取消是飞回原格,拖到仓库的取消可能是留在原位弹提示,拖到交易栏的取消可能是附带一条"该物品不可交易"的红字。格子状态机管"什么时候取消",取消之后演什么由注册回调的业务方决定——引擎又一次把"机制"和"内容"拆开,这套拆法从通知机制篇开始,你已经见过它第五次登场了。
配合 moveCancel 的还有灰显状态 _greyStatus:拖起一件绑定物品悬停在不可用目标上时,目标格灰掉,玩家在松手前就知道"放这儿没用"。反馈前置到松手之前,误操作率断崖式下降。我们统计过改造前后的数据:格子误操作工单从日均 7 条降到基本绝迹,而全部改动只是"把无效落点的判定从松手时刻提前到悬停时刻"。UI 反馈的时间点,比 UI 反馈的样式重要十倍。
还有一个字段是拆分场景准备的:叠加超过一格数量的药水、矿石,拖拽时玩家可能想"只拿一半",长按菜单里的拆分选项就是干这个的。拆分的交互细节(默认拆一半、可输入数量、余量自动回原格)都在长按菜单那一层实现,格子状态机只负责把长按事件递过去——手势层永远不知道菜单里有什么,职责分寸拿捏得刚好。状态机递事件,菜单管业务,两层解耦让后来加"批量使用""上架交易"都没碰过状态机一行代码。
下面这个演示把格子状态机整个搬来了:单击弹 Tips、长按看进度环走满弹菜单、拖拽带幽灵跟手、落格子外自动飞回;三个参数(长按阈值、拖动起步位移、双击开关)全部可调,你亲手把阈值调乱一次,就明白手势参数为什么不能乱给:
skill-goods-cell-1003d
某服二开把背包从 40 格扩到 80 格,上线后出现灵异现象:拖动物品偶尔"消失"——物品没丢,是飞回了看不见的旧坐标。排查三天,根子是扩展背包的格子坐标数组没跟着扩,拖拽落点判定用 40 格的旧表,第 41 格之后的物品拖起来,落点判定全部失灵,end_move 走了"格子外飞回"路径,飞向一个不存在的坐标。
修法不难,教训值钱:拖拽系统的坐标表必须和 UI 布局同源。格子的屏幕坐标由布局函数统一计算,拖拽判定只问布局函数"这个点在哪个格子上",绝不自建坐标表。那次治理顺手做了个防御:end_move 落点判定不到格子时,强制走"飞回原格"并打一条 warn 日志——飞回是兜底,日志是探针,下次再出布局错位,当天就能从日志看到。
第二课是拖拽动画的帧率预算。飞回动画用了物理缓动(先快后慢),80 个格子同时在飞的极端场景下,缓动计算本身不贵,贵的是每次位置更新触发的重排。治理方案:同屏飞行中的物品超过 6 个时,未完成的动画直接瞬移到位——玩家在操作风暴中根本看不清飞行动画,省下的就是赚到。动画是给安静时刻准备的,操作风暴里一切从简。
那次事故的最后一块拼图是回归测试的教训:扩背包的改动在测试环境全绿,因为测试号背包只有 23 件物品,第 41 格之后的路径根本没被踩到。此后我们给背包类改动加了一条硬性验收:造一个 80 格全满的测试号,把每一格的物品都拖一遍。边界数据造出来跑一遍,胜过十个"看起来没问题"。
问:长按和拖动的阈值怎么配合?
两个阈值互为保险:长按要求"按住不动满时长",拖动要求"位移超阈值",先动算拖、久按算长按,天然互斥。实测参数:长按 500 毫秒、拖动起步 14 像素,同时满足"想长按的手不会微抖成拖拽"和"想拖拽的手不会停顿成长按"。两个阈值要一起调,单独拉大一个会让另一个误判率上升。
问:双击使用会不会和拖拽冲突?
不会,拖拽判定在先且互斥:位移超阈值的按下永远不会被判成点击,自然进不了双击窗口。反过来快速双击的两次按下位移都很小,也进不了拖拽。手势互斥的前提是判定顺序固定——引擎的顺序(拖拽→长按→点击族)别动,动了就是事故。
问:绑定物品不让拖,玩家想整理背包怎么办?
允许拖但只允许"换回原位",或者在长按菜单里给"一键整理"入口。禁令要配出口,这是交互设计的一般原则——引擎的 moveItem 开关只管"能不能拖走",整理需求是界面的责任,两层各答各的题。
问:拖拽到一半来了个新邮件红点要刷新背包,怎么办?
拖拽优先。刷新逻辑检查 moveState ~= 无 时只更新数据不动格子布局——布局一动,玩家手里的落点判定全部失灵。战斗中弹公告不能打断挥刀,拖拽中弹红点不能打断拖拽,"用户正在做的动作拥有一切资源的优先权",这条原则贯穿背包、战斗、聊天所有系统。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…