
第一次看到 MiroFish 这个名字我脑子里蹦出来的第一反应是这玩意儿大概是把白板当鱼缸养鱼。后来跟几个做团队协作工具的朋友聊过之后发现这个判断基本没错而且它比想象中更有嚼头。MiroFish 是一套跑在协作白板上的多人养鱼互动应用团队成员各自拥有一条属于自己的鱼鱼生活在一块共享的白板鱼缸里你在白板上做的每一个动作——贴便利贴、投票、拖动任务卡、给同伴点赞——都会转化成鱼食喂给鱼吃让鱼成长、变色、产卵最后形成一个由整个团队共同养大的鱼群生态。它最适合三类人参考一是做团队破冰、敏捷回顾、工作坊引导的教练和 Scrum Master需要一个让人愿意动手的互动壳子二是前端和全栈开发者想找一个黑白板 SDK 加轻量后端的完整练手项目三是做企业内部文化建设的人需要一个能长期挂在白板上、每天都有变化的活体装置。全文围绕 MiroFish 的机制设计、参数推演、SDK 实操和踩坑记录展开代码可以直接抄参数可以按自己的团队规模改。1. 先把 MiroFish 拆开看名字里藏着两层信息1.1 Miro 这一半决定了场在哪里很多人在设计互动小游戏的时候第一反应是做一个独立网页让所有人打开链接进去玩。这个思路不能说错但放在团队场景里会立刻遇到一个致命问题多了一个入口。团队本来就已经在白板上干活了你让他们再开一个标签页、再登录一次、再对着一个小窗口操作参与率会断崖式下跌。我做过统计凡是需要额外打开一个链接的互动环节实际参与率通常只有六成左右剩下的四成人要么在发呆要么在假装网络卡。MiroFish 把场直接建在白板里这就绕开了入口问题。白板本身就是团队聚集的地方鱼缸就摆在白板角落谁进来都能看见。这一半决定了 MiroFish 的形态必须是寄生式的它不抢主舞台它依附在白板的工作流上安静地待在旁边等着你把注意力分给它一点。所以它天然适合白板的三种界面元素——面板、弹窗、以及白板上真实的图形对象。我个人的判断是MiroFish 里最值钱的鱼其实是白板上的那条鱼而不是面板里的那条。面板是操作台白板是展示窗两者分工必须清楚。新手最容易犯的错误就是把整套逻辑都塞进面板里结果用户要看鱼还得把面板拉出来久而久之就没人看了。1.2 Fish 这一半决定了玩法内核是什么Fish这一半其实定义了四件事它是活的、它会变、它需要照顾、它会留下痕迹。活的意味着状态会随着时间流逝而变化。如果一条鱼今天长这样明天还是这样那它就只是个图标不是鱼。所以 MiroFish 必须有时间驱动的状态演算哪怕用户什么都不做饱食度也应该往下掉。会变意味着需要有阶段感。鱼苗、小鱼、成鱼、鱼王每个阶段在视觉和行为上都该有区别。阶段感是长期留存的关键也是团队里想看看我的鱼长大了没这种念头的来源。需要照顾意味着必须存在某个只有人能做、系统做不了的动作。喂食、清理、安抚这些动作的意义在于制造责任有责任才有回访。留下痕迹是团队场景特有的。单机养鱼游戏不需要痕迹但团队需要——鱼群的整体状态就是团队协作状态的一面镜子。一个鱼缸里全是半死不活的鱼说明这个团队最近没什么互动鱼满为患、五彩斑斓说明大家状态不错。1.3 为什么不做成纯前端的小游戏这是我被问得最多的问题既然只是养个鱼纯前端用 localStorage 存一下不就行了何必搞后端我实测过纯前端版本撑不过三天。原因有三条。第一MiroFish 的核心资源来源是团队成员在白板上的行为而这些行为只有通过 API 才能被可靠采集前端拿不到别人的操作记录。第二鱼的状态需要脱离任何单个用户在线而持续演算你总不能让鱼只在有人打开白板的时候才吃饭。第三团队场景天然要求共享同一份真相纯前端必然出现每个人的鱼长得不一样的情况这在团队里是灾难性的。所以结论很明确MiroFish 必须是一个白板前端 轻量后端 持久化存储的三段式结构。这个结论会决定后面所有的技术选型值得在一开始就想清楚。2. 核心机制设计让鱼真的活起来2.1 四条状态轴饱食度、成长值、心情、寿命一条鱼在 MiroFish 里由四个数值描述我把它们叫作状态轴。这不是凑数每一条都负责一种心理感受。饱食度Satiety0 到 100 的浮点数决定鱼饿不饿。它随时间线性衰减是驱动用户回来喂食的主要动力。衰减太快会让人焦虑太慢会让人觉得没意思这是整个数值系统里最敏感的一个参数。成长值Growth从 0 开始累加没有上限决定鱼处于哪个成长阶段。它只增不减给人的是我一直在进步的确定感。注意它不能由时间直接驱动必须由喂食行为间接驱动否则用户会觉得我不管它它也会长大责任感就没了。心情Mood0 到 100由饱食度和团队整体活跃度共同影响。这个轴的设计目的很单纯——制造一点意外感。用户可能发现自己的鱼明明吃得饱心情却不高查一下才知道是因为团队这周互动变少了。这种被提醒的瞬间比任何消息通知都有效。寿命Lifespan以活跃天数为单位。鱼不会真的死掉这是刻意的设计。团队场景里让人经历失去是很危险的容易引发负面情绪。这里的寿命指的是鱼进入成年期后的天数用于解锁一些长期成就。四条轴的关系可以这样理解饱食度是燃料成长值是里程心情是仪表盘上的那颗警示灯寿命是日历。2.2 鱼食从哪来把白板行为映射成资源资源映射是整个 MiroFish 里最需要琢磨的部分。你希望用户多做什么就给什么行为多发鱼食。但同时又不能太粗暴否则大家会开始刷便利贴。我最后采用的方案是一张加权映射表把不同的白板事件换算成鱼食点数白板行为鱼食点数单日上限设计意图新建一张便利贴330 点鼓励表达但要防刷在便利贴上补充文字220 点鼓励深化而非数量堆砌参与一次投票515 点投票是稀缺行为单价高移动任务卡到完成列824 点推进流程最高单价给同伴的内容点赞110 点鼓励互相看见连续三天活跃20一次性长期留存奖励这张表有几个反直觉的地方值得说。第一点赞单价极低这和我最初的想法相反。我原本以为鼓励互评应该给高单价结果实测发现单价一高点赞立刻变成社交货币大家开始互刷点赞的意义就被稀释了。第二几乎每条都有单日上限因为一旦没有上限团队里总会有人用脚本或者机械重复把鱼食刷满整个生态就崩了。第三移动任务卡给最高单价这是因为在真实工作流里把卡片拖到完成列是真的干了活的信号值得重奖。提示这张表不要照抄一定要按你团队的实际工作流调整。如果你的团队主要在白板上做头脑风暴那就应该把新建便利贴的权重提上去如果主要在做任务跟踪那移动卡片才是核心动作。2.3 数值参数怎么定一次完整的计算推演参数拍脑袋定是做这类项目最大的坑。我第一次上线的时候饱食度设成每小时掉 10 点结果第二天早上整个团队的鱼全饿到 0一片惨状反而打击了积极性。后来我按下面的方式重新推演了一遍。先定核心目标一条鱼在用户每天正常参与一次团队活动的情况下应该稳定保持在饱食度 60 到 85 之间既不饿到红脸也不撑到溢出。假设团队每天有一次 15 分钟的白板例会一个普通成员在这段时间里平均产生约 25 点鱼食这是实测值差不多是 6 张便利贴加 1 次投票。再假设用户每天只在会上活跃一次两次喂食间隔按 24 小时算。那么要让鱼在 24 小时内从 85 掉到 60衰减速率就是(85 - 60) / 24 ≈ 1.04 点/小时取整设为每小时衰减 1 点。这样 24 小时掉 24 点一只 85 饱食度的鱼第二天早上还在 61正好落在目标区间内。再看成长值和饱食度的换算。喂食不是简单地把饱食度加满而是经过一次转化摄入食物 f 点 实际填入饱食度ΔS f × 0.8同时受 100 上限截断 转化为成长值 ΔG f × 0.6 溢出部分不浪费 溢出量 × 0.3 转成成长值这个设计里溢出部分不浪费很关键。用户如果在鱼已经吃饱的时候还继续喂系统不会白吃他的鱼食而是打三折转成成长值。这样一来勤奋的用户不会因为喂早了而吃亏也不会出现卡着点喂食的心机行为。成长阶段的阈值我设成了四个档阶段成长值区间视觉表现解锁能力鱼苗0 - 100很小灰白色无小鱼100 - 300有颜色会轻微摆动可以改名字成鱼300 - 700鲜艳有花纹可以产卵生成小鱼苗送人鱼王700 以上带光效体型最大可以给自己的鱼缸命名按每天 25 点鱼食、转化率 0.6 计算每天成长值增加 15 点。到 100 需要约 7 天到 300 需要约 20 天到 700 需要约 47 天。这个节奏对应的是一周见到变化、一个月形成习惯、一个半月拿到最高成就对一个企业内部工具来说是比较舒服的曲线。注意如果你的团队规模很小比如只有三五个人上面所有参数都要往上调一档否则鱼长得太慢大家会失去耐心。人数越少单次行为应该给的成长值越高。这是我在两个不同规模的团队里对比出来的结论。3. 技术选型与架构落位3.1 前端Miro Web SDK 的能力边界MiroFish 的前端由两部分组成一块跑在面板里的操作界面和一组画在白板上的图形对象。这两部分都通过 Miro Web SDK 来操作。面板部分本质是一个嵌入的 iframe用miro.board.ui.openPanel()打开里面是普通的 HTML/CSS/JS可以随便用你熟悉的前端框架。这里有个容易被忽略的细节面板里的代码运行在 iframe 里跟白板主页面是两个上下文你必须显式调用 SDK 的接口才能读写白板内容不能靠直接操作 DOM 蒙混过关。白板对象部分SDK 提供的能力大致包括读取当前所有对象、读取当前选中对象、创建便利贴/形状/文本/图片/卡片、修改对象的属性、监听选择变化和对象变化事件。对 MiroFish 来说最关键的三个能力是创建图片对象批量读取对象和监听对象创建事件。这里我要泼一盆冷水SDK 的能力边界决定了 MiroFish 的上限。尤其是有两个限制必须提前知道。第一白板上的图形对象不能做逐帧动画你没法让一条鱼真的游来游去最多是在一定时间间隔内改变它的位置和旋转角度视觉上像慢慢游。第二对象数量到一定规模后白板的渲染会变卡所以鱼缸里能放多少条鱼是有天花板的大概在 40 到 60 条之间比较安全。3.2 后端为什么必须有一个 tick 服务后端的核心是一个定时任务我们叫它 tick 服务。它每隔固定时间跑一次做三件事结算所有鱼的状态变化、拉取白板上的最新行为数据、把变化写回存储并推送到前端。tick 的间隔怎么定这是个典型的权衡。间隔太短接口调用频率高容易触发速率限制成本也上去了间隔太长用户喂完食看不到即时变化体验会打折。我的建议是双轨制一个快 tick负责响应用户的主动操作比如喂食这个不靠定时器直接由用户请求触发立刻结算一个慢 tick负责时间衰减和团队行为统计间隔设成 10 到 15 分钟就够。这样既保证了交互的即时感又把后台压力控制住了。慢 tick 为什么可以这么慢因为饱食度衰减本身是每小时 1 点这个量级15 分钟结算一次累计误差不到 0.25 点对用户体验毫无影响。而团队行为统计更不需要实时晚 15 分钟知道团队今天贴了 30 张便利贴完全没问题。3.3 存储与实时同步的取舍存储分两层。实时层用键值存储存每条鱼的当前四轴数值、所属用户、最后更新时间。这部分读写频繁、结构简单、允许偶尔丢失几分钟的数据键值存储是最合适的。历史层用关系型数据库存每天的状态快照、行为日志、成长里程碑。这部分数据量不大但需要长期保留用于后面做团队复盘看板。实时同步这块有个坑要提前说。白板的协作是实时的但你自己应用内部的多个面板之间并不是。如果三个人同时打开面板A 喂了食B 不一定马上看到。有两个解法一种是前端定时轮询后端接口实现简单缺点是有一点点延迟和额外请求另一种是建一条长连接通道主动推送体验好但要多维护一个服务。我的选择是轮询优先长连接后置。轮询间隔设 5 到 8 秒对这个场景来说足够了。等团队规模上去、用户抱怨延迟明显了再上长连接也不迟。过早优化是这类小工具最容易踩的坑我见过太多项目死在架构做得太漂亮但没人用上。4. 动手实现从创建应用到第一条鱼下水4.1 在开发者后台建应用拿到凭证第一步是在协作平台的应用管理后台创建一个新应用。你需要准备的几个东西应用名称就叫 MiroFish、重定向地址用来接收授权回调、以及申请的权限范围。权限范围我建议从最小的开始boards:read 读取白板内容 boards:write 在白板上创建和修改对象一开始不要把能申请的权限全点上。多余权限会让审核变慢也会让安装这个应用的管理员多一层顾虑。等确实需要了再加。创建完成后你会拿到一对凭证客户端 ID 和客户端密钥。密钥绝对不能放在前端代码里一旦写进面板的 JS任何人打开开发者工具都能拿到。正确做法是放在后端的环境变量里前端只拿一个短期令牌。授权流程是标准的 OAuth 三段式用户点击安装、跳转到授权页、带着授权码回到你的重定向地址、后端用授权码换访问令牌。这里有个实践细节值得提访问令牌会过期一定要把刷新令牌存下来并且实现自动刷新否则用户第二天打开白板会发现鱼不见了其实是接口全部 401 了。4.2 写面板 UI 与 SDK 初始化面板本质上就是一个网页。初始化 SDK 的代码大致长这样// panel.js 运行在面板 iframe 内 async function initMiroFish() { // 等待 SDK 就绪 await miro.board.ui.on(icon:click, async () { await miro.board.ui.openPanel({ url: /fish-panel.html, width: 340, }); }); // 拉取当前用户信息用于和鱼做绑定 const user await miro.board.getUserInfo(); const board await miro.board.getInfo(); // 拉取这条鱼的当前状态 const state await fetch(/api/fish/${board.id}/${user.id}).then(r r.json()); renderPanel(state); } initMiroFish();这里要注意 SDK 的加载方式。如果你用的是新版 SDK它会自动注入到页面上下文里你可以直接调用旧版本需要手动引入脚本。我踩过一次坑本地开发的时候一切正常部署到线上却报miro is not defined查了半天才发现是构建工具把 SDK 的注入脚本给 tree-shaking 掉了。解决办法是在构建配置里把 SDK 相关的模块标记为外部依赖不要参与打包优化。面板的 UI 布局建议保持极简我的实际做法是三段式顶部是鱼的当前形象和一句话状态描述中间是四个状态条底部是喂食按钮和排行榜入口。别塞太多东西面板宽度通常只有 340 像素上下信息一多就挤成一片。4.3 白板行为采集采集是 MiroFish 的技术核心。有两种思路主动拉取和被动监听。主动拉取是指慢 tick 跑的时候调用接口把白板上所有对象拉一遍然后跟你上次记录的快照做 diff算出新增了多少张便利贴、多少张卡片移动了位置。这个方式的好处是可靠、不漏事件、实现简单缺点是要全量拉取白板对象多了以后会比较慢。被动监听是在面板打开的时候注册事件回调用户一创建对象就上报。这个方式实时性好但只在面板打开时有效用户关掉面板就采集不到了。我的方案是以主动拉取为主被动监听为辅。慢 tick 每 15 分钟做一次全量 diff保证不丢数据面板打开时额外挂上监听让用户能立刻看到自己行为的反馈获得感更强。两者的数据在结算时按最大值取避免重复计数。diff 的粒度要注意。如果你对便利贴内容的每一次修改都算一次补充文字那用户改个错别字也会加鱼食很容易被刷。我的做法是只有当一张便利贴的文字长度增加超过 10 个字符时才计一次补充文字事件。这个阈值是实测调出来的5 个字符太松20 个字符太严。// 后端 diff 逻辑简化版 function diffBoard(prevSnapshot, currSnapshot) { const events []; const prevMap new Map(prevSnapshot.items.map(i [i.id, i])); for (const item of currSnapshot.items) { const prev prevMap.get(item.id); if (!prev item.type sticky_note) { events.push({ type: sticky_created, weight: 3 }); continue; } if (prev item.type sticky_note) { const delta (item.data?.content || ).length - (prev.data?.content || ).length; if (delta 10) { events.push({ type: sticky_expanded, weight: 2 }); } } if (prev item.type card prev.parentId ! item.parentId) { if (isDoneColumn(item.parentId)) { events.push({ type: card_completed, weight: 8 }); } } } // 单日上限裁剪 return applyDailyCap(events); }4.4 后端 tick 与状态结算结算函数是整个系统的心脏逻辑必须绝对确定——同样的输入必须得到同样的输出否则一旦出 bug 很难复现。// 状态结算先衰减再进食最后判定阶段 function settle(fish, elapsedHours, foodPoints) { // 1. 饱食度随时间衰减 const SATIETY_DECAY 1.0; // 点/小时 let satiety Math.max(0, fish.satiety - SATIETY_DECAY * elapsedHours); // 2. 进食转化 const FILL_RATE 0.8; const GROWTH_RATE 0.6; const OVERFLOW_RATE 0.3; const capacity 100 - satiety; const filled Math.min(foodPoints * FILL_RATE, capacity); const overflow foodPoints * FILL_RATE - filled; satiety filled; let growth fish.growth foodPoints * GROWTH_RATE overflow * OVERFLOW_RATE; // 3. 阶段判定 const stage growth 700 ? king : growth 300 ? adult : growth 100 ? juvenile : fry; // 4. 心情饱食度权重 0.7团队活跃度权重 0.3 const mood Math.round(satiety * 0.7 fish.teamActivity * 0.3); return { ...fish, satiety: Number(satiety.toFixed(2)), growth: Number(growth.toFixed(2)), stage, mood, updatedAt: Date.now(), }; }这段代码里有三个地方我在实践中改过。第一Number(x.toFixed(2))这个截断是必要的浮点数累加久了会出现62.99999999997这种值前端渲染状态条的时候会显示成 62%看着很难受。第二Math.max(0, ...)必须加否则时间跨度大的时候饱食度会变成负数。第三阶段判定我一开始用了switch加区间后来改成三元表达式纯粹是因为可读性更好性能上没区别。素材要和阶段绑定。四个阶段对应四张或者四组鱼的形象图我建议至少准备两套一套正常态一套饥饿态饱食度低于 25 时替换。饥饿态的鱼颜色暗淡一点、身体瘪一点用户一眼就能看出来该喂了。这套视觉反馈带来的提醒效果比任何弹窗通知都强。4.5 把结果画回白板结算完了要把结果呈现出来。这一步的实现在整个项目里最琐碎也最容易出问题。// 把鱼的状态同步到白板对象上 async function renderFishToBoard(boardId, fishList) { const existing await fetchBoardObjects(boardId, image); const fishMap new Map(fishList.map(f [f.userId, f])); // 已有对象更新图片和位置 for (const obj of existing) { const fish fishMap.get(obj.metadata?.userId); if (!fish) continue; const targetUrl resolveImageUrl(fish.stage, fish.satiety 25); if (obj.data?.url ! targetUrl) { await updateImage(boardId, obj.id, { data: { url: targetUrl }, geometry: { width: stageWidth(fish.stage) }, }); } } // 新用户创建一条新鱼放到鱼缸的随机空位 for (const fish of fishList) { if (existing.some(o o.metadata?.userId fish.userId)) continue; const pos findFreeSlot(existing); await createImage(boardId, { data: { url: resolveImageUrl(fry, false) }, position: pos, geometry: { width: 80 }, metadata: { userId: fish.userId }, }); } }这段代码有两个关键点。一是 metadata 的用法。你需要把这条白板图片对应哪个用户这个信息存下来否则下次同步就不知道谁是谁了。不同平台对自定义元数据的支持程度不一样如果平台不支持退而求其次的做法是在图片的标题或者描述字段里塞一个约定格式的字符串比如fish:user_1024读取的时候解析出来。二是位置分配。白板上所有人看到的鱼缸位置是共享的所以你不能随便放。我的做法是把鱼缸区域划成一个网格比如 8 列 × 6 行的格子每条新鱼占一个格子按加入顺序从左上往右下排。格子大小按鱼缸总宽度除以列数算这样即使有人用不同分辨率的屏幕相对布局也不会乱。提示白板上创建对象是有频率限制的一次同步里如果有几十条鱼需要更新务必做分批和延迟每批之间间隔 100 到 200 毫秒。我最早没做限流直接并发几十个请求结果一半返回失败鱼缸里的鱼缺胳膊少腿。5. 常见坑与排查手册5.1 权限与授权类问题授权这块的坑主要集中在三个地方。第一是重定向地址的精确匹配。大部分平台要求回调地址和你在后台登记的一模一样包括结尾的斜杠。https://example.com/callback和https://example.com/callback/在很多平台上被当作两个不同的地址。我被这个坑卡过整整一个下午最后是逐字符对比才发现的。第二是令牌过期处理。访问令牌通常有效期不长必须用刷新令牌换新的。这里有个容易忽略的点如果多个实例同时刷新令牌可能会互相把对方的令牌刷失效。解决办法是用一个分布式锁保证同一时间只有一个刷新动作在跑。第三是权限升级。如果后面你加了新功能需要申请新的权限范围所有已经安装了应用的用户都必须重新授权一次。这个体验很差所以一开始申请权限的时候就应该把可能用到的都算进去宁可多申请一两个不太敏感的也不要后面反复折腾用户。5.2 接口速率与并发冲突速率限制是这类应用绕不过去的坎。常见表现是返回 429 状态码严重的会被临时封禁。应对方式有三条一是加缓存。白板对象列表没必要每次全量拉可以只拉最近修改过的部分或者用一个短周期的缓存。我一般设 60 秒缓存慢 tick 间隔 15 分钟完全够用。二是加指数退避。遇到 429 不要立刻重试而是等待 1 秒、2 秒、4 秒、8 秒这样递增最多重试 4 次。这个策略几乎能解决所有的瞬时限流问题。三是加请求队列。所有对白板的写操作都先进一个队列串行处理或者严格控制并发数我一般设 3 到 5 个并发。虽然慢一点但稳定性完全不在一个档次。并发冲突是另一个问题。同一时刻两个人给同一条鱼喂食如果不加控制两个请求都读取到饱食度 50各加 10最后写回 60实际上应该是 70。解决办法是用乐观锁读取的时候记下版本号写入的时候检查版本号有没有变变了就重新读取再算一次。这个逻辑写起来不复杂但一定要有否则数据会慢慢漂移。5.3 白板渲染性能白板上的对象一多滚动和缩放都会变卡。我在测试的时候故意往白板上放了 200 条鱼结果拖动画布明显掉帧。控制手段有几个。限制鱼缸容量超过 60 条鱼之后就不再为新成员创建鱼而是让他们加入一个候补池等有人的鱼被回收比如长期不活跃再补位。降低图片分辨率鱼的图片不需要很高清宽度 400 像素足够文件大小控制在 100KB 以内。减少更新频率白板上的鱼不需要每分钟刷新10 分钟一次完全够。下面这张表是我整理的高频问题速查表遇到问题的时候可以直接对照现象最可能的原因排查方向处理方式面板打开一片空白SDK 未正确注入看控制台是否有变量未定义报错检查构建配置排除 SDK 被压缩打包接口全部返回未授权访问令牌过期检查令牌有效期和刷新逻辑补上自动刷新加失败重试鱼的状态不更新tick 服务未运行看服务日志和最近执行时间检查定时任务配置和进程状态同一条鱼数值跳变并发写冲突查看同一秒内的多次写入记录引入版本号做乐观锁部分鱼不显示写接口被限流统计创建请求的失败率加队列和指数退避白板操作变卡对象数量过多统计白板上的对象总数限制鱼缸容量压缩图片鱼食被刷满行为映射无上限检查单日上限是否生效补上每日配额和异常行为检测用户看不到自己的鱼白板快照缓存过期对比本地状态和服务端状态缩短缓存时间或提供手动刷新除了表格里的还有两个比较隐蔽的问题值得单独说。一个是时区。慢 tick 里如果用了服务器本地时间做每日重置那不同地区的用户就会在不同时间点重置配额看起来像 bug。所有和时间相关的计算都必须用统一时区或者时间戳。另一个是浮点精度。前面提过的toFixed只是显示层面的处理真正做数值比较的时候要留一个容差比如判断饱食度是否等于 0应该写成小于 0.01 而不是严格等于 0。6. 体验打磨与后续可扩展方向6.1 让鱼看起来真的在动前面说过白板对象没法做逐帧动画但不动和看起来在动之间还有很大的空间。我的做法是给每条鱼记录一个轻微的位置偏移量每次慢 tick 的时候把鱼的坐标在原有基础上随机挪动几个像素同时改变一点点旋转角度。用户刷新页面之后会发现鱼的位置变了虽然只是几像素但配合上鱼群整体布局观感上就有了在游动的感觉。更进一步的做法是分时错峰。不要所有鱼同时更新位置而是把更新任务打散到 tick 之间的各个时间点这样用户偶尔看白板的时候总能撞见几条鱼在动动态感会强很多。这个技巧成本很低但效果很好我个人认为性价比最高。还有一个小细节是饥饿态的切换。不要把饥饿态做成一个突变而是在饱食度从 30 降到 20 的过程中分三档切换图片正常、略饿、很饿。过渡自然了用户的紧迫感也会更强一些。6.2 数据看板与团队复盘MiroFish 玩久了会积累大量数据这些数据本身就是很好的团队复盘素材。我后来加了一个简单的看板展示过去四周的团队活跃度曲线、每个人的鱼食产出分布、以及鱼群整体的阶段构成。这个看板在敏捷回顾会上意外地好用。有一次我们发现某个人的鱼食产出连续两周都很低一问才知道他最近在忙别的项目参与度确实下降了。如果没有这个可视化这种安静的退出很难被察觉。需要注意的是看板的定位是观察工具而不是考核工具。一旦团队开始拿鱼食排名去评价绩效整个游戏就变味了大家会开始刷数据。我在看板上刻意不显示个人排名只显示分布和趋势。这个边界必须守住。6.3 可扩展的几个方向如果 MiroFish 跑得还不错后面有几个方向可以试试。鱼群互动。现在的鱼是孤立的可以加入鱼群效应——当某个区域的鱼数量超过一定密度它们的心情值会得到加成反之孤立的鱼会掉心情。这样能自然地引导大家把鱼放在一起形成视觉上的聚落。季节和事件。给鱼缸加一些周期性的环境变化比如每周三下雨、每月最后一天是丰渔节当天所有行为鱼食翻倍。这种节日感能显著提升长期活跃度成本还很低。物种和装饰。不同的行为类型解锁不同的鱼种或者给鱼加装饰物。这是最直白的商业化思路但放在企业内部工具里装饰物更适合作为成就奖励而非付费点。导出与纪念。允许团队把一个周期结束时的鱼缸导出成一张图片作为这个季度的纪念。这个功能我用得最多每次季度结束时导出一张贴在回顾文档里效果比任何文字总结都好。最后分享一个我在实际使用中的体会MiroFish 这类东西的价值从来不在于它本身有多好玩而在于它给团队提供了一个每天都能看一眼的共同焦点。人是有惰性的纯粹靠自觉去维护一个团队的连接感很难但如果白板角落里有几条鱼在等你喂那这件事就从一个应该做的任务变成了一个顺手就做了的习惯。我踩过几次坑之后最大的感受就是参数可以慢慢调玩法可以慢慢加唯一不能妥协的是它必须足够轻轻到不打断任何人的正常工作流。