我们的桌面 AI 应用有一个透明置顶的桌宠窗,里面是虚拟形象。用户会去点它:点头、点身体、按住不放、拖走。
早期版本的实现很直白:窗口上叠几个命中盒,头部盒的 pointer 事件里写一段「点一下弹一下 + 换个表情」,身体盒里再写一段差不多的,按住再加一段。三个区域乘两种手势,很快就变成六段高度重复的事件处理代码。
真正让这坨代码不可持续的不是重复,而是门控开始变多。产品上陆续要加:深夜不要有反应、关系还不熟时不要有太亲密的反应、同一个区域短时间连点不要重复触发。这些东西如果都塞进事件处理函数,每个命中盒都要复制一遍判断,而且判断藏在 UI 代码里,没法单测。
于是这个版本把交互改成了四段式管道。门控前置是它的核心原则:不合格的事件在进入动作构造之前就被丢掉,不留下任何副作用。
第一段:归一化,让下游只认一种事件
所有触发源产出的事件,先统一成同一个形状再往下走:
| 触发源 | 归一化后 |
|---|---|
| 指针命中盒 | 触摸事件:区域 + 手势 + 按下时长 |
| 系统感知 | 系统事件:屏幕空闲 / 锁定 / 唤醒 / 语音起止 |
| 时间上下文 | 上下文事件:深夜 / 久未对话 / 连续工作 |
| 关系演算 | 关系事件:等级 + 变化量 |
| 对话侧 | 对话事件:被提及 / 新消息 |
上游各写各的,下游只处理一个联合类型。这一步看着平淡,但它决定了后面所有逻辑的写法------门控、查表、执行都只需要写一遍,不用为每个触发源各写一份。
第二段:纯函数门控,先拦后造
门控决定「这次交互该不该有反应」,顺序是有讲究的,从便宜到贵、从全局到局部:
- 勿扰时段:夜间固定时段内的所有触摸都被拦掉,只放行「语音结束」这一种系统事件。这是优先级最高的一条,因为它代表用户明确的「别打扰我」。
- 关系等级 :某些「区域 + 手势」组合有最低等级门槛。陌生人长按头部不给反应(太亲密),把角色「提起来」需要搭档以上。门槛表按
区域::手势做键,等级不够直接返回空。 - 区域冷却:每个区域有各自的冷却毫秒数(头部比身体短,因为摸头本来就该能连着摸;被提起这种大动作冷却最长)。冷却判断只依赖「上一次触发时间戳」,由调用方维护并回填。
- 模板查表:以上全过,才去动作模板表里取链条。
任何一步不过,整次交互返回空,调用方跳过。注意门控全部在读上下文、不做任何修改------它是纯函数,输入一样输出必然一样,因此可以完全用单测钉死,不需要渲染器、不需要计时器、不需要 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 份;
- 门控前置 + 纯函数:不合格的事件在构造动作之前就被丢弃,判断逻辑因此可单测、可穷举;
- 执行器保持「笨」:只做类型分发,不做第二次节流,避免双重状态;
- 动作以配置表数据的形式存在,新需求优先落在数据上;
- 「即时反应」和「事件广播」是同一次触摸的两个语义,拆开才不会互相拖累;
- 需要按住的交互,值得一个显式的三段式阶段机,而不是散落在事件回调里的布尔量。