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

给博客养一条大肥鱼:桌宠的设计与实现
Eucalyptus前言
之前博客右下角挂着一个 AI 小助手:点开是面板,关掉是图标,说话要等接口,内容还要等打字机效果。
可它真正被用到的场景只有四种:回到顶部、跳去评论、关灯、看看这站是谁写的。
于是我把它拆了,换成一只常驻的大肥鱼。点它有反应,右键出菜单,第一次来会打招呼,还能告诉你离我多远。
这篇讲它的结构、几个关键设计,以及怎么配置。
一、它由哪几块组成
1 | themes/anzhiyu/ |
原则是「一个挂载点 + 一个 JS + 一个 CSS」,不往主题的模板里塞逻辑。
挂载点只有三件事:声明素材地址、引入样式、放一个空的 <video>:
1 | script. |
素材走自己的对象存储,换 CDN 只改这一行地址。
二、素材:透明视频
角色是带 alpha 通道的 WebM,一个文件同时解决「会动」和「没有底色」两件事——不用 GIF(体积大、边缘锯齿),也不用序列帧(要自己写播放器)。
目录约定很简单:
1 | <BASE>/idle.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 | var ctx = c.getContext('2d', { willReadFrequently: true }); |
两个细节是刻意设计的:
阈值取 100。 太低会把描边、阴影算进去,包围盒虚胖一圈;100 以上才是”实体”。
顶边要按行密度重新找。 头顶有一缕很细的呆毛,一两像素宽,却会把包围盒顶边拉到很高——气泡贴上去就成了”飘在天上”。所以再扫一遍每行像素数,取第一行够密的当头顶:
1 | var dense = Math.max(3, Math.round((vw / 2) * 0.03)); // 该行像素数 ≥ 全宽 3% |
这套坐标只依赖「当前这一帧」,所以角色换姿势时不用额外维护数据:打开气泡就现场量一次,拖拽时用位移平移,松手再精确贴一次。
四、气泡:一个组件,五种内容
公告、访客信息、友链推荐、关于我、文章摘要,全都复用同一套气泡,靠参数区分:
1 | showBubble({ |
同一套组件的几种样子:
![]() |
![]() |
![]() |
![]() |
设计上的几个取舍:
- 长文本:超过 70 字自动放宽到 340px,文本区最高
50vh,超出在气泡内滚动,绝不顶破屏幕。 - 内容变了就重新定位:取消友链跳转后文案从三行缩成一行,气泡矮了一半,位置要跟着重算,否则底下会空一大块。
- 尾巴对着角色中心(而不是矩形中心):鱼被拖到屏幕边缘时,尾巴仍然指着它。
- 层级分明:桌宠 9998,气泡 10002。气泡必须压在桌宠之上,因为桌宠的矩形框有透明区域,会拦截点击。
至于「什么时候弹」,是一套有节制的规则:一次只弹一个,切换内容就替换;同一个信息在一个会话里不重复打扰。
五、交互:点击与拖动
1 | wrap.addEventListener('pointerdown', ...); // setPointerCapture |
- 6px 阈值区分「点一下」和「拖一下」,否则手抖就变成拖动,点击反应永远触发不了。
- 位置持久化:
pointermove里 200ms 节流写一次localStorage,松手、失焦、页面卸载各兜一次底。 - 允许露在屏幕外:夹取边界用真实包围盒(
minX = -vis.l),鱼可以只露半个身子在边缘,不会卡在离边缘一百像素的地方过不去。 - 拖动时气泡跟随:按位移平移,不用重新采样,松手后精确贴边一次。
六、菜单:把主题的功能借过来
右键(触屏为长按)弹出菜单,两页:功能、表情。
功能页除了自己的入口(公告、访客信息、友链推荐、关于我),还接管了主题原本的右侧悬浮按钮:回到顶部、跳到评论、阅读模式、切换深浅、单栏/双栏。
做法是把那组按钮藏起来但不删掉:
1 | @media (min-width: 769px) { #rightside { display: none ; } } |
菜单项只是代理点击:
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 | document.addEventListener('pjax:send', () => { hideBubble(); closeMenu(); }); |
十、想改的话,改哪里
| 想改什么 | 改哪里 |
|---|---|
| 素材地址 | 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 秒缓冲可取消)、关于我、文章摘要,外加接管回顶/评论/阅读模式/深浅/单栏双栏。
回头看,这类”常驻小部件”的难点不在动画,而在两件事:知道自己占多大地方,以及知道什么时候该闭嘴。前者靠像素采样,后者靠一套「一次只弹一个、同一信息不重复」的规则。













