让一张精灵图动起来并不难。
定时修改 background-position,角色就会抬脚、落脚,看起来像在屏幕上走路。做到这一步,很容易觉得网页宠物的核心问题已经解决了。
然后需求开始长出来。它要待机、移动、悬停、点击、拖拽、睡觉、惊讶和庆祝;不同角色的素材命名不一样,帧数不一样,连每一帧该停多久都不一样。
这时继续把逻辑写在动画播放器里,最后得到的不会是一只更聪明的宠物,只会是一团越来越了解某张图片的代码。
我在 sprite-pet 里最想拆开的,就是「宠物想做什么」和「这套素材怎样把它演出来」。
行为说人话,素材保留自己的方言
产品层关心的是 idle、hover、drag、celebrate 这些语义。素材作者却可能把同一个动作叫作 stand、working、walk_left,也可能干脆只给它一个内部编号。
如果运行时直接依赖素材状态名,每换一只角色,点击、拖拽和优先级逻辑都要跟着改。素材越多,行为代码越像一个大型翻译现场。
所以状态机只说产品语言。适配层负责把「庆祝」翻译成某个角色的图集行列、方向、帧序列和持续时间。点击触发庆祝的规则只写一次,具体怎么庆祝留给角色规格。
这个边界一旦成立,换宠物才真正接近换资源,而不是悄悄重做半个应用。
五层听起来多,是为了让变化别到处传
我把这套运行时分成了五层。
- 规格层保存角色、图集几何、动画片段和精确帧时长。
- 适配层把统一行为翻译成素材里的源状态。
- 行为状态机处理待机、悬停、点击、拖拽等优先级与转换。
- 渲染层把当前帧显示为 CSS Sprite 或 Canvas。
- 宿主层决定宠物放在哪里、位置怎样保存,以及如何与应用通信。
这些层不是为了画一张漂亮架构图。它们分别挡住不同方向的变化。换渲染方式时,点击行为不该重写;换素材命名时,宿主不该知道;桌面窗口和网页 DOM 的坐标处理不同,也不该逼状态机分叉。
小系统不一定要分很多层,但变化已经来自五个方向时,假装它们是一件事只会把成本藏起来。
Runtime 可以拥有事件,不能顺便拥有整个世界
PetRuntime 负责指针跟踪、拖拽、行为触发和动画播放,因为这些动作共享同一份状态。如果每个宿主都绑定一套自己的事件,状态机很快会被复制成几个只有细节不同的版本。
但 Runtime 不直接决定最终布局。它把拖拽增量通知宿主,网站可以移动 DOM 节点,桌面应用也可以把增量换算成窗口坐标。运行时知道宠物被拖了多远,不需要知道整个应用怎样保存位置。
同样重要的是退出。谁创建事件监听、定时器和动画循环,谁就必须提供完整的 destroy()。网页宠物会反复挂载、切换和关闭,清理少一处,最先出现的通常不是报错,而是重复响应、幽灵拖拽,以及菜单都关了还在后台消耗 CPU 的循环。
能启动只证明创建路径存在,能安静消失才证明生命周期闭合。
动画的味道,藏在不均匀的时间里
固定 FPS 可以播放大多数精灵图,却未必能保留素材原来的节奏。一个起手帧可能需要多停一点,结尾帧也可能故意放慢。把所有帧平均以后,动作还是会动,但表情和重量会变得不太对。
这类差异很难在类型定义里看出来,真正播放时却很明显。
所以规格层要保留非均匀帧时长,播放器也要消费真实的时间信息。适配层不只翻译名称,还要确保源动画的节奏没有在统一模型时被抹平。
我以前会把这类细节当成素材问题。做完以后才更确定,时间本身就是数据,强行平均也是一种有损转换。
CSS 和 Canvas 不需要为了统一互相消灭
CSS Sprite 很适合普通 DOM。尺寸、命中区域和布局都直观,也容易和页面样式一起工作。Canvas 则适合已有的高 DPI 渲染与独立浮动组件。
两种方式都已经有合适场景,重构没有必要为了「架构统一」删掉其中一个。新的行为运行时可以驱动 CSS Sprite,原来的 Canvas API 继续支持本地文件、远程素材、浮动、拖拽和等比缩放。真正需要统一的是行为与规格,不是所有像素必须走同一条渲染管线。
代码与素材许可也遵循类似边界。运行时代码采用 MIT,不代表仓库里的角色美术自动获得相同许可。npm 包不携带角色素材,演示资源的授权范围单独说明。它们放在同一个项目里,责任仍然不是一回事。
真正可复用的不是动画代码,而是边界
网页宠物最让人开心的,当然还是它第一次在页面上走起来的瞬间。只是让它能走起来的代码,和让它能进入不同产品的代码,不是同一个层次的问题。
后者依赖一些不太显眼的约定。行为不认识素材名,Runtime 不控制宿主布局,渲染器不决定产品状态,代码许可也不覆盖第三方美术。
这些边界稳住以后,换一只宠物才不会牵动所有东西。
它仍然只是在屏幕上走了几步。
但这一次,后面的系统没有跟着它一起乱跑。