这篇记录整理今天复刻 Letter Badges 交互 demo 的过程。目标不是完全照搬截图,而是用 canvas 做出类似的粒子扰动、鼠标追随和隐藏角落彩蛋。

复刻目标

最初的画面重点有三个:

  1. 背景里铺满圆形字母徽章;
  2. 黑色小生物经过时,附近徽章被推开并变成彩色;
  3. 整体保持轻微漂浮感,而不是静态排版。

后续又加了一个隐藏交互:鼠标进入四个角时触发文字雨。上方角落出现“我想你了”,下方角落出现 LOVE

粒子徽章

徽章没有用 DOM 元素,而是全部放在 canvas 里绘制。每个徽章都保存自己的位置、原始位置、速度、旋转角度、颜色和漂移方向。

这样做的好处是状态很集中,适合每帧统一更新:

  • 小生物靠近时,根据距离给徽章一个向外的斥力;
  • 徽章离开原位后,用一个很小的回弹力拉回 homeX/homeY
  • 再叠加轻微随机漂移,让画面不死板;
  • 靠近小生物的徽章画成彩色实心圆,远处则保留灰色描边。

一开始徽章数量偏多,画面虽然密,但和文字雨叠加后容易增加绘制压力。后来把徽章密度减半,视觉上更清爽,也给后面的文字雨留出空间。

小生物追随鼠标

最初的小生物是直接跟着鼠标位置移动,反应太硬。后来改成 PID 风格的追随算法:鼠标只是目标点,小生物用比例项、积分项和微分项慢慢靠近。

这个改动让小生物更像一个有惯性的活物,但也暴露了一个问题:如果角落触发逻辑看的是小生物位置,PID 的延迟会导致鼠标已经进入角落,文字雨却还没出现。

最后的处理是把“触发逻辑”和“视觉表现”分开:

  • 鼠标位置负责判断是否进入隐藏角落;
  • 小生物位置只负责视觉运动和推开徽章;
  • pointer.active 控制小生物是否追随鼠标;
  • pointer.inside 控制鼠标是否还在画布内,从而决定文字雨能不能触发。

这个拆分很重要:用户期待的是“我把鼠标移到角落就触发”,而不是“等小生物慢慢追到角落才触发”。

隐藏角落和彩蛋

一开始四个角画了半透明色块,用来提示触发区域。实际看起来有点破坏画面,于是把区域隐藏,只保留逻辑判断。

四角判断很简单:根据 CORNER_SIZE 判断鼠标是否同时靠近左右边和上下边。左上、右上触发中文文字雨;左下、右下触发英文文字雨。

这里有一个细节:如果只在 pointermove 里触发,鼠标停住后文字雨就不会继续出现。所以最终把持续触发放进每帧 update() 里,只要鼠标还在角落,就按照冷却时间重复生成。

文字雨调试

文字雨经历了几轮调整:

  1. 从角落附近生成:效果太集中,不像满屏文字雨;
  2. 全屏随机生成:能铺满,但容易局部拥挤;
  3. 减少数量:不卡了,但画面有时显得空;
  4. 分栏随机生成:不增加数量,也能更均匀地覆盖整个画布。

最终使用横向分栏:每一批文字雨按画布宽度分成若干 lane,每个 lane 里随机一个 x 坐标。这样数量不变,但分布比纯随机稳定。

性能经验

这次最明显的性能问题来自文字雨。canvas 里每个雨滴每帧都要更新位置、旋转、透明度,并执行 fillText()。如果再加上 shadowBlur,成本会更高。

当鼠标停在角落时,文字雨会持续生成。如果只生成不限制,总数量会越来越多,每帧绘制压力也会越来越大。所以最后加了两个限制:

  • 降低每次生成的文字数量;
  • 设置 MAX_RAIN_DROPS,超过上限就删除最早的雨滴。

这个经验可以复用到其他 canvas 小 demo:粒子效果可以持续生成,但一定要有生命周期和总量上限。

今天的结论

这次复刻最大的收获是:交互 demo 不能只看“代码逻辑对不对”,还要看用户直觉是否一致。

PID 追随让运动更自然,但不能让触发反馈变慢;文字雨要铺满画布,但不能靠无限增加数量解决;隐藏区域可以存在,但不一定要画出来。最后的实现更像是几组取舍叠在一起:视觉上柔和,触发上直接,性能上有上限。

Demo 页面:/pretext-demo/letter-badges.html