桌宠点击反应:把一坨 if 拆成「归一化 → 门控查表 → 执行 → 广播」

我们的桌面 AI 应用有一个透明置顶的桌宠窗,里面是虚拟形象。用户会去点它:点头、点身体、按住不放、拖走。

早期版本的实现很直白:窗口上叠几个命中盒,头部盒的 pointer 事件里写一段「点一下弹一下 + 换个表情」,身体盒里再写一段差不多的,按住再加一段。三个区域乘两种手势,很快就变成六段高度重复的事件处理代码。

真正让这坨代码不可持续的不是重复,而是门控开始变多。产品上陆续要加:深夜不要有反应、关系还不熟时不要有太亲密的反应、同一个区域短时间连点不要重复触发。这些东西如果都塞进事件处理函数,每个命中盒都要复制一遍判断,而且判断藏在 UI 代码里,没法单测。

于是这个版本把交互改成了四段式管道。门控前置是它的核心原则:不合格的事件在进入动作构造之前就被丢掉,不留下任何副作用。

第一段:归一化,让下游只认一种事件

所有触发源产出的事件,先统一成同一个形状再往下走:

触发源 归一化后
指针命中盒 触摸事件:区域 + 手势 + 按下时长
系统感知 系统事件:屏幕空闲 / 锁定 / 唤醒 / 语音起止
时间上下文 上下文事件:深夜 / 久未对话 / 连续工作
关系演算 关系事件:等级 + 变化量
对话侧 对话事件:被提及 / 新消息

上游各写各的,下游只处理一个联合类型。这一步看着平淡,但它决定了后面所有逻辑的写法------门控、查表、执行都只需要写一遍,不用为每个触发源各写一份。

第二段:纯函数门控,先拦后造

门控决定「这次交互该不该有反应」,顺序是有讲究的,从便宜到贵、从全局到局部:

  1. 勿扰时段:夜间固定时段内的所有触摸都被拦掉,只放行「语音结束」这一种系统事件。这是优先级最高的一条,因为它代表用户明确的「别打扰我」。
  2. 关系等级 :某些「区域 + 手势」组合有最低等级门槛。陌生人长按头部不给反应(太亲密),把角色「提起来」需要搭档以上。门槛表按 区域::手势 做键,等级不够直接返回空。
  3. 区域冷却:每个区域有各自的冷却毫秒数(头部比身体短,因为摸头本来就该能连着摸;被提起这种大动作冷却最长)。冷却判断只依赖「上一次触发时间戳」,由调用方维护并回填。
  4. 模板查表:以上全过,才去动作模板表里取链条。

任何一步不过,整次交互返回空,调用方跳过。注意门控全部在读上下文、不做任何修改------它是纯函数,输入一样输出必然一样,因此可以完全用单测钉死,不需要渲染器、不需要计时器、不需要 DOM。

这条设计还有个副产品:门控输出的不只是「做不做」,还有「这次的冷却时间戳要不要更新」。调用方在拿到结果之后统一回填,避免了每个命中盒自己管一套计时。

第三段:配置表 + 执行器

动作模板表是纯数据,键是 区域::手势,值是一条动作链:

字段 含义
actions 动作数组,按顺序同步执行
priority 优先级(用于将来做仲裁,当前只在链上标注)
cooldownMs 该链建议的冷却时长

动作类型是有限的联合,执行器就是一个 switch:

  • 生命周期:interaction.start / interaction.end(三段式状态机的入口)
  • 形象反馈:playSquash(弹性形变)、flashExpression(限时表情)、setExpression(常驻表情)、tap(一次性动作)
  • 文案:subtitle.show(本地短句气泡,带 i18n key)
  • 未实现:event.fire、chat.request(执行器里显式告警或跳过,留给后续版本)

执行器是故意做得很笨的:它不做节流、不做优先级仲裁、不做延迟------因为门控已经在前面把「该不该执行」判完了。这一刀切得很重要:节流逻辑一旦散到两个地方,就会出现「明明门控放行了,执行器又拦了一次」这类难查的双重状态。

这套表驱动的好处在这个版本里直接兑现了:「点击形象触发一次动作演出」这个功能,只往两条动作链里各加了一个 { type: 'tap' }。 没有任何渲染代码改动,因为动作类型和执行路径早就就位了。配置表的价值不在于少写代码,而在于新需求能落在数据上而不是逻辑分支上。

第四段:广播,把「反应」和「副作用」解耦

管道最后一步是把归一化后的事件再广播一次,交给订阅方(事件导演、感知层、统计)。

这一步解决的是一个耦合问题:触摸事件天然是两个语义------「角色该做个反应」(演)和「发生了一次交互」(记)。前者要立刻得到视觉结果,后者可能要触发关怀剧本、要记入交互统计、要用于关系演算。如果都写在一个处理函数里,任何一方加需求都要动事件处理代码,而且很容易在触摸处理里写出阻塞视觉响应的事情。

拆成「先执行、再广播」之后,订阅方是异步旁路:广播压力不会影响点击的即时反馈,订阅方各自决定要不要处理、怎么节流。

三段式状态机:为什么交互需要一个显式的阶段

触摸有个特点:它有「按下」和「松开」两个时刻。点击是一次性的,长按是持续的,而形象的反应需要知道「现在处于按住状态」,才能把动作做成「入场 → 维持 → 收尾」三段。

我们借用了开源虚拟桌宠项目 VPet 的动画模型:它给每个交互定义 A_Start(入场)、B_Loop(持续按住时循环维持)、C_End(松手后收尾)三段。转成状态机就是:

  • 按下 → 进入 start,固定时长后自动转 loop;
  • 按住期间再次收到按下信号(重复触发)只延长计时,不重开------这对应 VPet 里「继续播放」的语义;
  • 松手 → 进入 end,固定时长后回 idle;
  • 收尾期间新的按下被丢弃(避免两个反馈互相截断)。

这个状态机只产出两样东西:当前阶段 ,以及基于阶段算出的叠加微倾角度。它不碰骨骼、不碰表情------写入由专门的姿态层和表情层负责。状态机只管「现在是什么阶段」,是最容易单测的那一半。

收尾阶段还有一处必要的强制动作:必须取消当前的限时表情。否则动作链里挂的那个「限时表情 2600 毫秒」会独立计时,用户已经松手了、角色还在笑,看起来像卡住了。

小结

  • 多触发源的交互,先归一化再往下走,否则门控会按触发源复制成 N 份;
  • 门控前置 + 纯函数:不合格的事件在构造动作之前就被丢弃,判断逻辑因此可单测、可穷举;
  • 执行器保持「笨」:只做类型分发,不做第二次节流,避免双重状态;
  • 动作以配置表数据的形式存在,新需求优先落在数据上;
  • 「即时反应」和「事件广播」是同一次触摸的两个语义,拆开才不会互相拖累;
  • 需要按住的交互,值得一个显式的三段式阶段机,而不是散落在事件回调里的布尔量。
相关推荐
日耗10亿Token被老板赶出门2 天前
REA 火了:一个 Electron 按钮的调用链,它能追到哪一步?
javascript·electron
一铭111992 天前
海豹办公 1.9.15 跨端办公软件实践 文档兼容 Access 数据库与移动 WebView 排错
android·数据库·electron·开源软件·access
不可能片场2 天前
工程实践 原子替换改了 ctime 怎么修
前端·electron
PegasusYu2 天前
Node.js使用nedb轻量级数据库测试脚本
数据库·electron·node.js·脚本·测试·轻量级·nedb
flash俊杰3 天前
Windows 安装器与标签发布流水线:让「出一个正式安装包」变成推一条标签
electron
flash俊杰3 天前
深色模式、无障碍门禁与零依赖自研 i18n:发布前的完检三件套
electron
不可能片场3 天前
Electron 发布进程被回收 用脱离进程组托住
前端·electron
月走乂山5 天前
DSH 桌面版用 DeepSeek 报 REQUEST_EXTENSION 的根因定位与修复
electron·node.js·插件系统·deepseek·bug排查·dsh·deepseek harness