桌宠点击反应:把一坨 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 份;
  • 门控前置 + 纯函数:不合格的事件在构造动作之前就被丢弃,判断逻辑因此可单测、可穷举;
  • 执行器保持「笨」:只做类型分发,不做第二次节流,避免双重状态;
  • 动作以配置表数据的形式存在,新需求优先落在数据上;
  • 「即时反应」和「事件广播」是同一次触摸的两个语义,拆开才不会互相拖累;
  • 需要按住的交互,值得一个显式的三段式阶段机,而不是散落在事件回调里的布尔量。
相关推荐
不可能片场1 天前
内容运营自动化 配图折行别把词切两半
前端·electron
黄连木笛1 天前
不改一行 Postman 源码,把它汉化了:Electron preload 注入 + fetch 拦截实战
javascript·electron
不可能片场2 天前
Electron 发布 正文写入的三条通道与读回口径
前端·electron
不可能片场3 天前
AI视频成片 帧间差分把拼接缝照出来
前端·electron
Noir热爱生活3 天前
deepseek harness,关于桌面版无法打开vscode修复方案
electron·deepseek
Dream-Y.ocean4 天前
鸿蒙PC平台星图遥感适配实战:基于 Electron 壳方案的纯 JavaScript 遥感图像处理引擎开发
javascript·electron·harmonyos
薛定谔的猫-菜鸟程序员4 天前
Electron开发超详细教程(从入门到放弃)
前端·javascript·electron
北城笑笑4 天前
Vue 106 ,上篇:前端桌面端应用实现方案( Electron 框架详细介绍,Vue + Electron )
前端·javascript·vue.js·python·electron
不可能片场5 天前
Electron 发布 别拿 load 事件当页面就绪
前端·electron
lpfasd1236 天前
pywebview与Electron
前端·javascript·electron