CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

0.3秒和200:两个数字撑起的点击手感

2026-10-03 08:14 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

双击地图寻路、双击背包穿装备、双击好友私聊——传奇玩家二十年的肌肉记忆里全是双击。但很少有开发者想过:引擎是怎么知道你"想双击"而不是"点了两次"?996 引擎的答案在 logic/mouseEventController.lua,核心是两个数字:0.3 秒和 200。今天把这套鼠标事件识别拆开,你会发现最平凡的输入代码里全是取舍。

这个文件的开头是一大段 read me 注释,把整个事件参数表摊开给你看——enter/leave/inside 三兄弟管悬停,down_r/up_r 管右键,double_l 管双击,moving/scroll 管移动滚轮,swallow 管阻断。这在这套源码里是个异类:别的文件靠你啃代码,它靠注释迎客。写这段注释的引擎作者显然被问烦过——输入系统的问题十有八九是"不知道有这个参数",与其一遍遍解释,不如把菜单贴在门口。这种"把文档写在代码最显眼处"的习惯,推荐给每一个写基础组件的人。

一、双击的本质:一次有窗口期的悬而未决

双击的难点在于"第二击永远无法预知"。玩家第一次点击的瞬间,引擎不知道他是单击还是双击的开头,只能先挂起。看引擎的字段和判定:

lua
function mouseEventController:ctor()
    self.onDoubleEvent = false           -- 当前是否处于"双击窗口"中
    self.doubleClickDelayTime = 0.3      -- 双击窗口:0.3秒
    self.lastInsideNodeParam = nil       -- hover 态记忆
    self.mouseMoveOffPos = nil
end

function mouseEventController:OnMouseMoveEvent(pos)
    if self.onDoubleEvent then
        if not self.mouseMoveOffPos then
            self.mouseMoveOffPos = pos   -- 记录第一击的位置
        else
            -- 位移平方和超 200(约14px)→ 取消双击意图
            local offPos = math.pow((self.mouseMoveOffPos.x - pos.x), 2)
                         + math.pow((self.mouseMoveOffPos.y - pos.y), 2)
            if offPos > 200 then
                self.onDoubleEvent = false
                self.mouseMoveOffPos = nil
            end
        end
    end
end

设计读下来就三句话:第一击后开 0.3 秒的窗口;窗口内第二击落在附近才算双击;窗口内鼠标跑出 14 像素(200 开方)就算意图取消。最后这条是精髓——玩家手滑点了一下又拖走,0.3 秒后不该凭空蹦出一个单击。窗口关闭时的处理很安静:不触发任何东西,等真正的单击逻辑走自己的延时确认。输入系统的所有复杂度都在这"悬而未决"的 0.3 秒里:你要同时伺候单击党、双击党和手滑党。

把这套状态机画成玩家视角的时间线更直观:第一击落下,系统进入"存疑期";这 0.3 秒内玩家的任何行为都在给系统喂证据——第二击落点近,判双击;鼠标位移大,判拖动或取消;什么都没发生,超时判单击。三种可能、一个窗口、零额外状态。你自己实现"单击还是双击"时最常犯的错是加一堆布尔标志互相打架,引擎这个实现只用了 onDoubleEvent 一个标志加一个位置缓存,状态空间干干净净。状态机的字段数就是它的出错面,能用一个标志说清楚的,别用三个。

0.3 秒这个值值得单独说。窗口越长,双击越宽容但单击越迟钝(单击要等窗口关闭才确认);越短则反之。0.3 秒是桌面鼠标时代沉淀的甜点值,Windows 系统双击速度默认值也是 500 毫秒内可调、游戏普遍收得更紧。你要改这个数,先想清楚游戏里单击的密度——传奇里单击是主要操作(点地走路),所以引擎收得比操作系统紧,0.3 秒是"不拖慢单击"和"容得下双击"的平衡点。

二、位移为什么用平方和:一个开方都不舍得

注意判定写的是 offPos > 200,不是"距离大于 14 像素"。14 像素的平方约等于 200——引擎直接拿位移的平方和与 200 比,省掉了那次开方。鼠标移动事件一秒触发上百次,每个事件省一次开方,积少成多。这是全引擎性能美学的一个缩影:掉落物管理用切比雪夫距离避开开方,这里用平方和避开开方,高频路径上的数学,能整数不浮点,能平方不开方。

但平方判定有个隐含语义要心里有数:位移阈值是正方形的(横纵平方和),不是圆形。斜向 45 度能容忍的位移比横向多 41%。对"判断手滑"这个用途完全够——没人需要手滑判定精确到圆。又想起行为树那句老话:数据结构跟着规模走,判定精度跟着用途走。

hover 三兄弟 enter/leave/inside 的状态机也在这个文件里,靠 lastInsideNodeParam 记住"上一帧鼠标在哪个节点上":

lua
function mouseEventController:SetLastInsideNode(node, param)
    self.lastInsideNodeParam = nil
    if node and param and not tolua.isnull(node) then
        self.lastInsideNodeParam = {
            sideNode = node,     -- 记住这个节点
            param = param        -- 连同它登记的回调集
        }
    end
end

function mouseEventController:LeaveLastNode()
    local lastData = self.lastInsideNodeParam
    if lastData and lastData.sideNode and not tolua.isnull(lastData.sideNode)
       and self:GetNodeLastState(lastData.sideNode) then
        self:SetNodeLastState(lastData.sideNode, false)
        local param = lastData.param
        if not param or not next(param) then
            return
        end
        local leaveCallBack = param.leave
        if leaveCallBack then
            leaveCallBack()      -- 上一帧的节点收到离开通知
        end
    end
end

每帧鼠标移动,引擎拿当前节点和记忆节点比对:变了就给旧节点发 leave、给新节点发 enter,没变就发 inside。按钮的悬停高亮、悬停提示框、鼠标手势变化全是这三兄弟的消费者。GetNodeLastState 这种把状态直接挂在节点身上的写法(node._nodeState)也是老引擎特色——状态跟着宿主走,宿主销毁状态跟着消失,跟 Buff 挂 Proxy 那篇讲的"状态跟语义走"是一个原则的具体化。

hover 这套还有个使用纪律值得交代:enter 和 leave 必须成对设计。你在 enter 里改了节点颜色,就得在 leave 里改回来;开了悬停提示框,就得在 leave 里关掉。漏一半的表现是"鼠标划过去,高亮留在那不走了"——这是 UI 工单里最经典的截图款。防法也简单:写 enter 时立刻把 leave 写完,两段代码挨着放,评审时一眼看出成对不成对。悬停提示框还有一层:它的显示有 0.2 秒延迟(防划过闪烁),这个延迟和双击窗口一样属于"输入系统的确认期",玩家早已习惯,别画蛇添足改成即时显示,划过一排格子的瞬间提示框会闪成霓虹灯。

mermaid
flowchart TD
A[鼠标移动事件] --> B{处于双击窗口?}
B -->|是| C{首次记录位置?}
C -->|是| D[记 mouseMoveOffPos]
C -->|否| E{位移平方和 > 200?}
E -->|是| F[取消双击意图 onDoubleEvent=false]
E -->|否| G[维持等待]
B -->|否| H[常规 hover 分发]
H --> I{当前节点 == 记忆节点?}
I -->|变节点| J[旧节点 leave / 新节点 enter]
I -->|没变| K[inside 持续回调]

三、swallow:事件优先级的方向盘

事件参数里有个 swallow = number, > 0 stopPropagation 的注释——吞掉事件,阻断继续分发。这是 UI 层叠时最重要的一根杠杆:背包格子叠在聊天框上,点背包时事件不许穿透到聊天框,格子的事件参数里 swallow 置 1 就够了。

引擎的分发顺序是自顶向下遍历节点树,谁先声明吞谁先得。这套机制用好了是利器,用砸了是灵异事件之源:某个透明装饰层不小心开了 swallow,它盖住的整个区域的点击全部失效,玩家看到的却是"点击没反应"。查这类问题有个快速二分法:把可疑区域的上层节点逐个隐藏,点到哪一层恢复响应,凶手就是哪一层。我们组的 UI 评审清单里因此有条规矩:任何节点的 swallow 必须写注释说明吞什么、为什么吞,无注释的 swallow 一律打回。

swallow 的消费时序还有个容易踩的暗坑:它阻断的是"后续分发",不是"已分发的回调"。也就是说,同一次点击里排在你前面的节点已经收到了事件,你 swallow 拦不住他们——吞的是身后人的机会,不是身前人的结果。有个服做过"全屏遮罩防误触",把 swallow 开在遮罩上,结果遮罩之下的按钮照样收到事件,因为遮罩的节点序在按钮之后。修法是把遮罩的注册顺序提前,或者干脆用 setTouchEnabled(false) 从源头关掉受控区域的触摸。分发的次序语义要背下来:先注册先得,swallow 只管身后,这句口诀能帮你省掉一整类"遮罩无效"的排查夜。

下面这个演示把整套识别器搬到了你的鼠标上:在画布里单击(等窗口关闭变绿)、快速双击(紫色判双击)、按住拖动(超阈值判拖动并取消双击意图)、移进移出看 hover 态切换;两个滑块分别调双击窗口和位移阈值,调到 600 毫秒感受一下"单击迟钝"是什么滋味:

demo
skill-mouse-dbl-1003c

四、实战案例:双击穿装备误触事件

某服做了"双击背包直接穿装备",上线三天收到 217 条误触投诉:玩家想双击捡地上东西,手抖点在背包格子上,刚捡的武器被顶掉了。排查发现两个阈值打架:地图双击和背包双击共存,但背包格子只有 40 像素见方,两次点击落点差 20 像素在"双击合法"范围内——手指抖一下就把两件不同的装备当成同一件的双击。

修法分两层,都是收窄而非新增。第一层,格子双击的位移阈值从 200 收到 40(约 6.3 像素),装备操作是高危动作,判定从严;第二层,给穿装备加了 0.5 秒的二次确认锁,同一格 0.5 秒内的第三次点击不响应,手抖三连的最后一击被吸收。误触投诉三天清零,操作流畅度的负面反馈只有 4 条。数字记下:高危操作双击阈值 40,普通浏览双击阈值 200,这组对比后来成了我们组的默认配置。

那次还顺手治了一个连带病:双击和拖动的边界冲突。玩家双击背包的第二击带了一点点拖拽(超了位移阈值),双击失败又不算拖动,两次点击凭空蒸发,玩家的感受是"点了没反应"。补丁是在拖动判定上加一个 30 毫米的起步死区(位移平方 900 以内不算拖动),微抖的双击不再被拖动逻辑抢走。三个阈值——双击窗口、拖动起步、双击位移——是一条连续的手感光谱,动任何一个都要连带验证另外两个,单独调一个必出新的边界事故。输入阈值从来不是三个数字,是一张联动的关系网。

这件事的底层教训是:阈值不是全局常数,是操作风险等级的函数。引擎给的是默认值和机制,风险分级是你的责任。凡是你不忍心让玩家误触的操作(穿装备、吃元宝药、解散公会),阈值一律从严加确认锁;浏览、选择、悬停类,放宽让手感跟手。

五、常见疑问

问:移动端没有双击概念,这套代码怎么办?
源码注释里那句 special_r : imitate left button down on mobile 就是答案:移动端把右键事件族映射成"特殊左键",双击映射成双触。映射层在 Controller 入口做,识别器的 0.3 秒和 200 原样复用。输入设备会变,识别逻辑的骨架二十年没变。

问:单击延迟 0.3 秒,玩家会不会觉得卡?
看单击的用途。传奇的点地走路是高频单击,引擎对走路做了旁路:单击立即执行,如果 0.3 秒内来了第二击,按双击语义追加处理,单击不吃延迟。需要延迟确认的是"单击和双击含义冲突"的控件(比如既是打开又是复制的格子),旁路设计让九成场景无感。

问:swallow 层级多了之后事件链条怎么排查?
给事件分发加一行日志打印"事件名、命中节点、swallow 与否",开一次跑一圈主界面,整个点击链路一目了然。和通知机制那篇的排查三板斧同源:输入系统的 bug 全在链路上,把链路打出来,答案自己浮出来。

问:滚轮事件 scroll 的参数怎么用?
注释里写得直白:scrollOff = { x = 0, y = 1 },x 目前没启用,y 大于零向后滚、小于零向前滚。背包、技能栏的横向翻页全靠它。自己接滚轮时记得做方向和惯性判断——滚轮一格 y 是 1,但不同鼠标的滚轮刻度不同,按 y 的正负号做逻辑、别按绝对值做步长,否则有的鼠标滚一屏有的滚三屏。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

一把钥匙开全引擎的配置门:MetaValue元变量系统

全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…

2026-10-03 11:55 996 技术组
进阶实战游戏功能

ui.按钮名就能拿到控件:LuaExtend元表的八十行魔法

读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…

2026-10-03 11:30 996 技术组
进阶实战游戏功能

断线是谁先发现的:心跳三兄弟与切后台补发

玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…

2026-10-03 11:24 996 技术组
进阶实战游戏功能

全引擎的下一拍在这里敲:主循环Update解剖

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…

2026-10-03 11:17 996 技术组
进阶实战游戏功能

元宝数字为什么全屏同步:CostItemCell消耗格台账

玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…

2026-10-03 11:10 996 技术组
进阶实战游戏功能

每个东西都有专属座位:sceneGraph功能节点树

新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…

2026-10-03 11:02 996 技术组