给博客养一条大肥鱼:桌宠的设计与实现

前言

之前博客右下角挂着一个 AI 小助手:点开是面板,关掉是图标,说话要等接口,内容还要等打字机效果。

可它真正被用到的场景只有四种:回到顶部、跳去评论、关灯、看看这站是谁写的。

于是我把它拆了,换成一只常驻的大肥鱼。点它有反应,右键出菜单,第一次来会打招呼,还能告诉你离我多远。

这篇讲它的结构、几个关键设计,以及怎么配置。

一、它由哪几块组成

1
2
3
4
5
6
7
8
9
10
themes/anzhiyu/
├── layout/includes/pet.pug # 挂载点(10 行)
└── source/pet/
├── pet.js # 全部逻辑,约 1000 行
├── pet.css # 全部样式,约 370 行
└── LICENSE # 素材与代码的 MIT 协议

scripts/pet-data.js # 构建期生成 pet-data.json
source/_data/pet_about.yml # 「关于我」文案
source/_data/pet_visitor.yml # 博主坐标(算距离用)

原则是「一个挂载点 + 一个 JS + 一个 CSS」,不往主题的模板里塞逻辑。

挂载点只有三件事:声明素材地址、引入样式、放一个空的 <video>:

1
2
3
4
5
6
script.
window.__BLOG_PET_BASE__ = 'https://img.eucalyptus.cc/pet/';
link(rel="stylesheet", href=url_for("/pet/pet.css"), media="print", onload="this.media='all'")
#blog-pet(aria-hidden="true", hidden)
video#blog-pet-video(autoplay, muted, loop, playsinline, preload="auto")
script(src=url_for("/pet/pet.js"), defer)

素材走自己的对象存储,换 CDN 只改这一行地址。

二、素材:透明视频

角色是带 alpha 通道的 WebM,一个文件同时解决「会动」和「没有底色」两件事——不用 GIF(体积大、边缘锯齿),也不用序列帧(要自己写播放器)。

目录约定很简单:

1
2
<BASE>/idle.webm            待机,循环
<BASE>/actions/*.webm 点击反应,播完回到待机

加载策略围绕「别拖慢首屏」设计:

资源 大小 时机
idle.webm 441 KB 首屏,CDN 缓存一年
点击动画 ×3 ≈1.5 MB 浏览器空闲时预取
pet.js / pet.css 12 KB / 2.6 KB(gzip) 每页,JS defer、CSS 非阻塞

点击动画不跟着首屏抢带宽,等 requestIdleCallback(最多 4 秒)再拉;省流量模式和 2G 直接跳过。CSS 用 media="print" + onload 切回来,不占关键渲染路径,代价是样式到达前 <video> 是裸的——所以 #blog-pet 先带 hidden,等 JS 量好尺寸再摘掉。

三、坐标系:量出角色真正占的地方

播放器给的是 460×259 的矩形,但角色只占中间一块。这个差别决定三件事:拖到屏幕边缘时按什么夹取、气泡贴在哪里、菜单从哪边弹。

所以用 canvas 读一帧的 alpha 求包围盒:

1
2
3
4
var ctx = c.getContext('2d', { willReadFrequently: true });
ctx.drawImage(video, 0, 0, vw, vh);
var data = ctx.getImageData(0, 0, vw, vh).data;
if (data[(y * vw + x) * 4 + 3] > 100) { /* 只认实体像素 */ }

两个细节是刻意设计的:

阈值取 100。 太低会把描边、阴影算进去,包围盒虚胖一圈;100 以上才是”实体”。

顶边要按行密度重新找。 头顶有一缕很细的呆毛,一两像素宽,却会把包围盒顶边拉到很高——气泡贴上去就成了”飘在天上”。所以再扫一遍每行像素数,取第一行够密的当头顶:

1
2
3
4
var dense = Math.max(3, Math.round((vw / 2) * 0.03));  // 该行像素数 ≥ 全宽 3%
for (var yy = minY; yy <= maxY; yy += 2) {
if ((rows[yy] || 0) >= dense) { headY = yy; break; }
}

这套坐标只依赖「当前这一帧」,所以角色换姿势时不用额外维护数据:打开气泡就现场量一次,拖拽时用位移平移,松手再精确贴一次。

四、气泡:一个组件,五种内容

公告、访客信息、友链推荐、关于我、文章摘要,全都复用同一套气泡,靠参数区分:

1
2
3
4
5
6
7
showBubble({
tag: '访客信息', tagIcon: 'icon-location-dot',
text: '...',
ms: 30000, // 0 = 不自动关
link: '/about/', linkText: '关于本人 →',
countdown: 5, onFire: fn // 倒计时跳转 + 取消按钮
});

同一套组件的几种样子:

公告气泡 友链推荐:5 秒倒计时,可取消
关于我 文章摘要

设计上的几个取舍:

  • 长文本:超过 70 字自动放宽到 340px,文本区最高 50vh,超出在气泡内滚动,绝不顶破屏幕。
  • 内容变了就重新定位:取消友链跳转后文案从三行缩成一行,气泡矮了一半,位置要跟着重算,否则底下会空一大块。
  • 尾巴对着角色中心(而不是矩形中心):鱼被拖到屏幕边缘时,尾巴仍然指着它。
  • 层级分明:桌宠 9998,气泡 10002。气泡必须压在桌宠之上,因为桌宠的矩形框有透明区域,会拦截点击。

至于「什么时候弹」,是一套有节制的规则:一次只弹一个,切换内容就替换;同一个信息在一个会话里不重复打扰。

五、交互:点击与拖动

1
2
wrap.addEventListener('pointerdown', ...);   // setPointerCapture
wrap.addEventListener('pointermove', ...); // 超过 6px 才算拖
  • 6px 阈值区分「点一下」和「拖一下」,否则手抖就变成拖动,点击反应永远触发不了。
  • 位置持久化:pointermove 里 200ms 节流写一次 localStorage,松手、失焦、页面卸载各兜一次底。
  • 允许露在屏幕外:夹取边界用真实包围盒(minX = -vis.l),鱼可以只露半个身子在边缘,不会卡在离边缘一百像素的地方过不去。
  • 拖动时气泡跟随:按位移平移,不用重新采样,松手后精确贴边一次。

六、菜单:把主题的功能借过来

右键(触屏为长按)弹出菜单,两页:功能、表情。

右键菜单:功能页

功能页除了自己的入口(公告、访客信息、友链推荐、关于我),还接管了主题原本的右侧悬浮按钮:回到顶部、跳到评论、阅读模式、切换深浅、单栏/双栏。

做法是把那组按钮藏起来但不删掉:

1
@media (min-width: 769px) { #rightside { display: none !important; } }

菜单项只是代理点击:

1
document.getElementById('darkmode').click();

display:none 的元素依然能收到 .click(),事件照常触发;真删了 DOM,代理就断了。移动端不藏——桌宠在 ≤768px 不显示,藏了就没有入口。

七、访客信息:中文地名 + 英文坐标

「欢迎来自 XX 的朋友」需要两样东西:地名、距离。麻烦在于中文地名源通常不给坐标,给坐标的源通常只有英文,于是并行请求两个:

来源 提供 备注
ip.zxinc.org/api.php?type=json 中文省市(「中国 / 陕西省 / 西安市」) 支持 CORS,无经纬度
api.ip.sb → ipapi.co → ipinfo.io 经纬度 三级降级,各 3.2 秒超时

地名用中文源的(去掉「省/市」后缀,显示成「陕西 西安」),距离用英文源的坐标算 Haversine。结果按会话缓存,再点秒出。

隐私上只到城市级:不显示 IP 原文,博主这边的坐标也只存市中心,距离本身就说明了一切。

访客信息气泡

八、什么时候主动说话

桌宠不该话多,所以主动开口只有两个场景:

  • 首次访问:用访客信息打招呼(「嘿,从 XX 飘过来的朋友 / 离我 N 公里」+ 时段问候)。一个会话只打一次。
  • 切到文章页:等页面的 AI 摘要写完,再以「本文摘要」念一遍(最多等 10 秒,没有就退回正文首段)。

公告没有自动弹,改成菜单入口——想看的自己点,不打扰不想看的人。文案的哈希会记录在 sessionStorage 里,改一次公告,老访客也会重新收到一次。

时段的问候语按大肥鱼的口吻写:

时间 文案
0–6 这个点还不睡?我眼皮都快抬不起来了 😴
6–12 早上好呀,今天也一起摸鱼吧 🐟
12–18 下午好,喝口水歇会儿再卷 ☕
18–24 天黑啦,早点收工回家 🌙

九、和 PJAX 共存

主题开了 PJAX,切页时只替换 #body-wrap。桌宠挂在 layout.pug 的 body 层,不在替换范围内,所以切页不重载、不重播入场。

代价是要自己接事件:

1
2
document.addEventListener('pjax:send',     () => { hideBubble(); closeMenu(); });
document.addEventListener('pjax:complete', () => { maybeShowArticleSummary(); });

十、想改的话,改哪里

想改什么 改哪里
素材地址 themes/anzhiyu/layout/includes/pet.pug 里的 __BLOG_PET_BASE__
公告文案 _config.anzhiyu.yml 的 aside.card_announcement.content(卡片关了也有效)
关于我 source/_data/pet_about.yml({years} 会按建站年份自算)
坐标 / 距离基准 source/_data/pet_visitor.yml
菜单项、气泡逻辑 themes/anzhiyu/source/pet/pet.js
视觉样式 themes/anzhiyu/source/pet/pet.css

后记

它现在做的事:点击有反应、右键菜单、公告、访客信息、友链推荐(5 秒缓冲可取消)、关于我、文章摘要,外加接管回顶/评论/阅读模式/深浅/单栏双栏。

回头看,这类”常驻小部件”的难点不在动画,而在两件事:知道自己占多大地方,以及知道什么时候该闭嘴。前者靠像素采样,后者靠一套「一次只弹一个、同一信息不重复」的规则。