MiroFish 是一个把“养鱼”搬到桌面角落的小项目,第一眼看上去很像那种开了就忘的桌面挂件,但真跑上几天就会发现它并不只是循环播放几帧动画那么简单:鱼会自己找食、会累、会跟着光照和水温状态改变游动节奏,甚至在你两天没开机之后,它会按离线时长补算一整套生态变化,而不是傻等你回来喂。我关注它、拆它、也自己动手改过它的参数和素材,很大一部分原因是我需要在一个长期挂着工作窗口的环境里,有一块不抢焦点、不弹通知、不吃性能的“活物区域”。MiroFish 恰好卡在这个位置上——它是一个轻量桌面水族箱模拟项目,解决的是“想要一点陪伴感,但不想为此开一个 3D 游戏”的别扭需求,适合桌面美化爱好者、想入门桌面端图形与状态机的新手,以及想拿现成框架做二次开发的人。下面我把这套东西从设计思路到落地细节,按我自己踩过的顺序完整讲一遍。
1. MiroFish 到底是什么:项目定位与整体设计取舍
先把范围划清楚。MiroFish 本质上是一个常驻桌面的 2D 水族箱模拟器,核心由四件事组成:一是鱼的行为模拟,二是水体的环境数值,三是把这两者画出来的渲染层,四是让用户能隔三差五喂一口食、加点装饰的交互层。它没有联网对战、没有排行榜、没有每日任务,这些在别的项目里是卖点,在这里反而是负担。我个人的判断是,MiroFish 真正想做的不是“游戏”,而是“环境”——你不会盯着它看半小时,但你一天会瞥它十几眼,而那十几眼每一次看到的画面都不太一样。
这决定了一堆后续取舍。比如为什么它不做显眼的新手引导?因为引导意味着弹窗,弹窗意味着打断。为什么它的操作入口都塞在系统托盘的右键菜单里?因为桌面主体区域要留给鱼,任何常驻的按钮框都会破坏那种“窗户外面有片水”的感觉。理解了“低打扰”这条主线,后面所有的技术选择你都能自己推导出来,不用死记。
1.1 从“桌面宠物”到“桌面生态”的需求变迁
早期的桌面宠物基本都是一个套路:一只角色在屏幕底部走来走去,点一下跳一下,动作循环就那么几组,看三天就腻。它的问题不在于做得不好,而在于它只有“表演”没有“状态”。你今天看它和下周看它,除了你自己换了个壁纸,感受完全一样。
MiroFish 这一类项目的转向,是把“表演”换成“状态驱动的表演”。鱼游得快,可能是因为饿了在找食;鱼贴着水底不怎么动,可能是光照周期到了夜间模式;鱼扎堆在角落,可能是水体清洁度掉到阈值以下。动作还是那几个动作,但因为背后有数值在推,呈现出来的组合就有变化了。这种设计思路在其他领域也常见——比如天气小组件,单纯显示“晴”和根据温度、湿度、风力算出来的“体感晴”是两回事,后者才有信息量。
对比一下就很清楚:
| 维度 | 传统桌面宠物 | MiroFish 这类生态型项目 |
|---|---|---|
| 驱动来源 | 定时器触发的随机动作 | 数值状态 + 权重决策 |
| 用户操作 | 点击互动,无长期影响 | 投喂、清洁、布置,有积累效应 |
| 时间感 | 无,关掉再开一模一样 | 有,离线也会推进 |
| 疲劳周期 | 2 到 3 天 | 通常能撑两到四周 |
| 性能占用 | 低 | 略高,但可控制在极低水平 |
1.2 技术选型:为什么没上游戏引擎
这是我被问得最多的一个问题:既然有动画、有物理感、有状态机,为什么不用现成的游戏引擎?答案很直接——目标平台是“常驻桌面”,而不是“独立窗口里跑一款游戏”。这两者的约束完全不一样。
常驻桌面的第一个硬指标是内存和冷启动。一个引擎运行时的基础开销,哪怕是个 2D 轻量引擎,往往也比这个项目全部素材加起来还大。第二个硬指标是透明窗口和点击穿透。桌面生态项目必须让鼠标能穿过鱼身点到下面的浏览器,这件事在引擎里通常要绕一圈去改窗口创建参数,而在原生窗口 API 里就是几个标志位的事。第三个是打包体积,几十兆和几兆,对“随手装一个试试”的转化率影响非常大。
所以 MiroFish 走的是轻量路线:宿主用一个基础窗口 + 系统托盘,渲染用 2D 精灵图和软件/轻量 GPU 合成,逻辑层是纯状态机加定时器。素材是精灵表,配置是文本文件,用户想改数值直接开记事本就行。这套土办法看起来不先进,但在这个场景下每一项都是加分项。我在自己的机器上实测,常驻内存稳定在几十兆量级,空闲时 CPU 占用基本可以忽略,风扇不会因为它转起来——这两条对桌面挂件来说是生死线。
1.3 分层结构:五个层次各管什么
我会把 MiroFish 这套东西在心里拆成五层,改动之前先想清楚自己动的是哪一层,能省掉大量返工。
- 素材层:精灵表、背景贴图、装饰物、字体。只管画什么,不关心什么时候画。
- 模拟层:鱼的属性、行为状态机、环境数值、时间推进。只管算什么,不关心画面。
- 渲染层:读取模拟层的输出,决定这一帧画哪些图、画在哪、缩放多少。
- 交互层:托盘菜单、投喂动作、拖拽摆件、快捷键。
- 持久化层:存档读写、版本迁移、离线时间结算。
新手最容易犯的错是把逻辑写进渲染回调里,比如“这一帧如果鱼的位置在左边就让它往右游”。这种写法当天能跑,改到第三天就会变成一锅粥,因为你没法在不启动窗口的情况下测试鱼的行为。分层之后,模拟层可以单独跑一个循环打日志验证,这才是可维护的前提。
2. 核心机制拆解:鱼怎么“活”起来
模拟层是整个项目最有嚼头的地方,也是我改得最多的地方。它要同时满足两个互相矛盾的要求:看起来要随机自然,跑起来要可预测可调试。纯随机数驱动的结果不是自然,是抽搐;纯确定性的结果是规律,是机器人。中间那条线就是权重决策加冷却时间。
2.1 行为状态机:给每条鱼一套决策逻辑
一条鱼在同一时刻只处于一个状态,状态之间靠条件切换,这就是最朴素的状态机。MiroFish 里常见的基础状态大概有这几个:巡游、觅食、进食、躲避、休憩、濒死。每个状态定义三件事——进入条件、退出条件、持续时间范围。
下面是我按常见实现写的伪代码,语言上我用 JavaScript 表达,逻辑是通用的:
const STATES = { cruise: { min: 4000, max: 12000, weight: 60 }, seekFood:{ min: 3000, max: 9000, weight: 30 }, rest: { min: 8000, max: 20000, weight: 25 }, flee: { min: 800, max: 2000, weight: 5 } }; function pickNextState(fish, env) { const pool = []; for (const key in STATES) { const s = STATES[key]; let w = s.weight; // 饥饿值越高,觅食权重越大,但被冷却压制 if (key === 'seekFood') { w += Math.floor(fish.hunger / 4); if (Date.now() - fish.lastSeek < 60_000) w = 0; } // 水质差或夜间,休憩权重上升 if (key === 'rest') w += env.cleanliness < 40 ? 20 : 0; if (key === 'flee') w = env.threat ? 80 : 0; for (let i = 0; i < w; i++) pool.push(key); } return pool[Math.floor(Math.random() * pool.length)]; }这段代码里有两个关键技巧值得单独说。第一个是权重轮盘用数组复制实现,而不是算累积概率再取随机数。在权重都是小整数的情况下,数组复制写起来最直观,性能也完全够用,几百条鱼以下不会有任何问题。第二个是冷却压制,觅食状态在刚觅食完之后权重直接置零一分钟。没有这条冷却,鱼会在吃饱之后继续疯狂找食,看起来像饿死鬼,这就是典型的“参数对了但观感不对”。
注意:状态权重不要写成绝对值,写成“基础权重 + 修正项”的形式。后面调平衡的时候你只需要动修正项,基础节奏不会被破坏。
2.2 转向与移动:为什么鱼看起来僵硬
移动实现是新手和老手最容易拉开差距的地方。错误的做法是每帧给鱼一个随机方向向量,结果就是抖。稍微好一点的做法是目标点加直线插值,结果就是急转弯。真正看起来像鱼的做法,是目标点 + 最大转向角速度 + 速度缓动。
具体来说,每条鱼有一个朝向角。每帧它计算自己到目标点的方向,然后比较这个方向和当前朝向的夹角。如果夹角超过单帧最大转角,就只转那么多;否则直接对齐。速度则按距离目标点的远近做缓动,靠近时减速,避免在目标点附近来回穿过。
还有一个细节:尾摆动画的播放速度要和实际移动速度绑定。鱼慢慢游的时候尾巴慢慢摆,加速冲刺的时候尾巴频率明显变快,这个视觉反馈比任何特效都能提升“活着”的感觉。我做的时候是把移动速度归一化到 0 到 1,映射到动画帧时长上,快的时候帧时长缩短到原本的三分之一,慢的时候拉长到两倍。改完这一处,观感提升比改十个美术素材都明显。
2.3 数值系统:饥饿、清洁度、心情的平衡点
数值设计最怕拍脑袋。随便定个“每分钟涨一点饥饿”,跑一天下来数字会爆掉,因为一天有一千四百四十分钟。所以第一步永远是先定周期,再反推速率。
我的做法是:假设一条鱼在完全不管的情况下,从饱到饿需要一整天(1440 分钟),饥饿值从 0 涨到 100。那么速率就是:
100 / 1440 ≈ 0.0694 点/分钟 ≈ 4.17 点/小时这个数一算出来,所有参数就都有参照了。一次投喂回复 35 点,那大约能顶 8.4 小时;一天喂三次差不多能维持住,偶尔漏一次不会崩。清洁度我按三天见底来算,也就是约 1.39 点/小时。心情值不单独衰减,它是另外两个值的加权结果:
mood = 0.5 * (100 - hunger) + 0.3 * cleanliness + 0.2 * (100 - crowding)其中 crowding 是密度压力,鱼越多越高。这个公式的好处是它可以显示成一根条,但用户不需要理解公式,只需要知道“饿了心情掉、水脏了心情掉、鱼太多心情也掉”。
| 数值 | 满值 | 衰减参考 | 恢复方式 | 归零后果 |
|---|---|---|---|---|
| 饥饿 | 100(饱) | 约 4.2 点/小时 | 投喂,单次 +35 | 进入濒死状态,游速减半 |
| 清洁度 | 100 | 约 1.4 点/小时 | 清洁操作,单次 +50 | 心情持续下滑,鱼扎堆角落 |
| 心情 | 100 | 由上式推导 | 饥饿与清洁度回升 | 影响活跃度与繁殖意愿 |
| 密度压力 | 0(越低越好) | 按鱼数阶梯上升 | 换大缸或减少鱼数 | 加速情绪下滑 |
2.4 时间轴与离线结算:一周不开机怎么办
这个是关系型模拟项目的经典难题。鱼的状态是随时间连续的,但程序不是常驻的。MiroFish 的做法是在存档里存一个lastTick时间戳,启动时用当前时间减去它,得到真实流逝时长,然后一次性补算。
但直接补算会有灾难性后果。你出差一周回来,所有鱼全都饿到濒死,心情归零,用户的第一反应是关掉卸载,而不是愧疚地喂食。所以这里必须加两个保护:补算上限和衰减系数。我用的配置是单次最多补算 12 小时,超出部分只按 12 小时算;同时离线期间的衰减速率乘以 0.7,模拟“鱼在没人管的时候自己找到了点吃的”。
# mirofish.config.yaml 片段 offline: max_catchup_minutes: 720 # 单次最多补算 12 小时 decay_multiplier: 0.7 # 离线衰减打七折 min_hunger_after_catchup: 15 # 补算后饥饿值不低于 15,避免开局濒死最后那条min_hunger_after_catchup是我自己加的,原版没有。加上之后,“出差两周回来”这个场景从劝退变成了有点惊喜——鱼还活着,只是瘦了点、水浑了点,你顺手清一清、喂一顿,体验很顺。这类兜底参数在文档里基本不会写,但它是决定项目能不能长期留着不删的关键。
3. 关键实现环节:从透明窗口到存档落地
机制想清楚之后,落到代码上有几个环节是绕不过去的,而且每一个都有明确的坑。我按从外到内的顺序讲:窗口、渲染、存档、性能。
3.1 无边框透明窗口与点击穿透
常驻桌面项目的第一步,是把窗口做成“看起来不存在”。需要配置的组合大致是:无边框、置顶、背景透明、不进任务栏、不接受焦点、鼠标穿透。
这几个属性里,最容易出问题的是焦点和穿透。很多人一开始做成透明了,但点桌面的时候点到鱼身上,窗口被激活,工作被打断,这就废了。正确的做法是让整个窗口默认点击穿透,只有在用户从托盘菜单进入“编辑布局”模式时才临时关闭穿透。
注意:不同系统对透明窗口的合成方式不一样,某些环境下透明区域会出现黑色底或者边缘一圈暗边。遇到这种情况优先检查合成器设置,而不是去改素材的 alpha 通道,方向反了会白忙半天。
多显示器和 DPI 缩放是另一个高频翻车点。用户在 4K 屏上把缩放调到 150%,你的窗口坐标是按逻辑像素算的,鱼就会跑到屏幕外。稳妥的处理方式是所有位置都用相对坐标存,也就是以窗口宽高的百分比表示,只在渲染时转成绝对像素;同时监听缩放变化事件,重新计算一次。
3.2 精灵表规范与帧调度
素材组织上,MiroFish 用的是经典的精灵表:横向是帧序列,纵向是动作行。命名上我建议统一成fish_<种类>_<动作>.png,动作名固定几个英文单词,方便代码里直接拼字符串取图。
关键的调度逻辑是这样:鱼对象里存一个animTimer,每帧累加,超过当前帧时长就切下一帧。帧时长不是固定的,它由基础帧时长除以速度系数得到,这就是前面说的“尾摆绑定速度”。
function updateSprite(fish, dt) { const base = SPRITE[fish.species][fish.action].frameMs; // 基础帧时长 const speedFactor = 0.4 + fish.speed01 * 1.6; // 映射到 0.4x ~ 2.0x const frameMs = base / speedFactor; fish.animTimer += dt; while (fish.animTimer >= frameMs) { fish.animTimer -= frameMs; fish.frame = (fish.frame + 1) % SPRITE[fish.species][fish.action].count; } } function updateSwim(fish, dt) { const dx = fish.target.x - fish.x; const dy = fish.target.y - fish.y; const dist = Math.hypot(dx, dy); // 到点附近减速,避免过冲抖动 const targetSpeed = Math.min(fish.maxSpeed, dist * 0.8); fish.speed += (targetSpeed - fish.speed) * Math.min(1, dt / 200); // 朝向以最大角速度趋近,产生弧线而不是折线 const want = Math.atan2(dy, dx); let diff = want - fish.angle; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; const maxTurn = fish.turnRate * dt; fish.angle += Math.max(-maxTurn, Math.min(maxTurn, diff)); fish.x += Math.cos(fish.angle) * fish.speed * dt; fish.y += Math.sin(fish.angle) * fish.speed * dt; fish.speed01 = Math.min(1, fish.speed / fish.maxSpeed); }注意while循环而不是if。这是个不起眼但很致命的细节:如果某一帧卡顿了很久,dt很大,用if只会前进一帧,动画看起来像卡住;用while会一次性跳过应有的帧数,虽然会丢帧但视觉上连续。低帧率场景下这个差别非常明显。
3.3 存档结构:JSON 里该放什么
存档我倾向于用纯文本 JSON,方便用户自己备份和手改。结构上分三块:元信息、环境、鱼列表。
{ "version": 3, "savedAt": 1735689600000, "env": { "cleanliness": 72.5, "light": "day", "decor": ["rock_a", "plant_c", "castle_small"] }, "fishes": [ { "id": "f_8a31", "species": "guppy", "born": 1734307200000, "hunger": 41.2, "mood": 68.0, "state": "cruise", "angle": 2.13, "pos": { "x": 0.37, "y": 0.62 }, "traits": { "speed": 0.9, "appetite": 1.1 } } ] }里面有两个字段值得解释。pos用的是 0 到 1 的相对坐标,原因前面说过,为了适配不同分辨率和缩放。traits是每条鱼的个体差异系数,速度快的、饭量大的各不同,这是让一群鱼看起来不像复制体的最低成本手段——不需要多画一张素材,改个乘数就够了。
version字段一定要有。我第一次改数值结构的时候偷懒没加,结果老存档加载直接报错,鱼全没了。加上版本号之后,写一个迁移函数,把 version 1 的字段名映射到 version 3,几行代码的事,但能救回所有老用户的存档。
3.4 性能与功耗:让它安静地待着
桌面常驻项目如果风扇一转,用户第一件事就是关掉它,所以性能优化不是可选项。我实际用下来,收益最大的三个手段是:
第一,分档帧率。窗口可见且鼠标在附近时跑 30 帧;窗口可见但长时间无交互时降到 10 帧;窗口被完全遮挡或最小化时降到 1 帧只做时间推进。这三档切换带来的功耗差异非常大。
第二,脏矩形重绘。不要每帧清空整个画布重画。只重绘上一帧和这一帧有鱼经过的区域,背景和装饰物缓存成静态图层。
第三,渲染与逻辑解耦。逻辑更新可以按固定步长跑(比如每 50 毫秒一次),渲染只管把当前状态画出来。这样即使帧率很低,鱼的移动速度也不会变慢。
| 优化阶段 | 空闲 CPU 占用 | 常驻内存 | 备注 |
|---|---|---|---|
| 未优化(全屏重绘 60 帧) | 明显可感,风扇会起 | 偏高 | 典型新手版 |
| 加脏矩形 | 大幅下降 | 基本不变 | 收益最大的一步 |
| 再加分档帧率 | 几乎不可测 | 略降 | 空闲时最舒服 |
| 最后逻辑渲染解耦 | 与上一步持平 | 略增 | 低帧率下速度不再变慢 |
4. 常见问题与排查实录
这部分是我自己折腾的时候记下来的,按出现频率排序。有些问题看起来像 bug,其实是配置或系统环境导致的,方向错了会查很久。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 透明区域出现黑底 | 系统合成未启用或窗口标志位冲突 | 检查合成设置,确认透明标志位,不要动素材 alpha |
| 鼠标点不到下面的窗口 | 点击穿透未开启,或编辑模式没退出 | 确认穿透开关状态,检查是否卡在布局编辑模式 |
| 鱼卡在屏幕边缘抖动 | 目标点在边界外,转向角速度无上限 | 目标点做边界内缩,转向每帧限制最大角度 |
| 重启后鱼全部消失 | 存档写入中断,或版本字段缺失 | 加临时文件写入再改名,加版本迁移 |
| 4K 屏上位置错乱 | 用了绝对像素坐标 | 全部改成相对坐标,监听缩放变化 |
| 长时间运行后变卡 | 对象不断创建未回收,或日志无限增长 | 复用鱼对象,日志轮转或关闭调试输出 |
| 多显示器上跑到副屏 | 窗口坐标以主屏为原点计算 | 用虚拟桌面总坐标,或按显示器结构体取值 |
| 动画明显卡顿一跳一跳 | 帧切换用 if 而非 while | 改为 while 循环消耗累计时间 |
4.1 排查思路:先分层,再二分
遇到问题的第一件事不是猜,而是判断它属于哪一层。鱼不动,是模拟层的状态机没切,还是渲染层没画?最快的方法是打开调试输出,把鱼的状态、位置、速度打出来。如果数值在变但画面不动,问题在渲染层;如果数值压根不变,问题在模拟层或时间推进。
二分法在排查资源问题时特别有效。把装饰物全删掉再跑,看 CPU 占用有没有变化;把鱼的数量从 20 降到 2 再跑,看有没有变化。几次下来就能定位到是哪个环节吃资源,比逐行读代码快得多。
4.2 几条不外传的经验
不要在渲染循环里做字符串拼接。我最早把鱼的状态文字用模板字符串每帧拼一次,二十条鱼跑起来看不出来,二百条鱼的时候 GC 抖动非常明显。改成状态变化时才更新文本,问题直接消失。
参数改动一定要留记录。我改数值改了十几轮,有几次改完觉得“不如上一版”,但记不清上一版是多少。后来我在配置文件里加了个注释块,每次改动写上日期和原因,回滚才有依据。这个习惯在调平衡的时候价值极高。
素材先做灰模再上色。我自己画鱼的时候一开始就上色,画了十条效果都不满意。后来改成先用纯灰块确认体型、锚点、游动时的姿态变化,确认没问题再上色,效率高了不止一倍。锚点位置尤其重要,锚点错了鱼会像在水面滑行而不是游动。
给用户留一个“重新开始”的出口。生态型项目最怕用户把状态玩崩之后没法恢复。菜单里放一个重置选项,看起来很消极,但它是防止用户直接卸载的最后一道防线。
5. 扩展与二次开发:把它变成自己的东西
MiroFish 最舒服的一点是它的数据都是明文,素材都是标准图片,改起来门槛很低。我在原版基础上做了几处扩展,思路可以复用。
5.1 自定义鱼种:素材规范与参数落点
加一个新鱼种需要准备一张精灵表,纵向每一行对应一个动作,行的顺序要和配置文件里的动作列表一一对应。尺寸上建议单帧控制在 64 乘 64 像素以内,太大在低分辨率屏幕上会糊,太小在高分屏上又会明显锯齿。
配置文件里新鱼种的条目大致是这些:精灵表文件名、动作行索引、基础帧时长、最大速度、转向角速度、饥饿系数、成长阶段对应的缩放比例。这里我建议速度参数用相对值,比如以默认鱼的速度为 1.0,新鱼填 0.8 或 1.4,而不是填绝对的像素每秒。这样以后你要整体调节奏,只改一个全局系数就行,不用一条条改。
5.2 脚本接口:让外部事件影响水体
一个我玩得很开心的扩展,是把 MiroFish 接上自己的日程状态。做法是让它每几秒读一次本地的一个状态文件,文件里写着当前是“专注中”还是“休息中”。专注中时水体变暗、鱼的活动范围缩小、游速放慢,营造安静感;休息时灯光变亮、鱼开始活跃,正好提醒你该站起来走两步。
这个接口不需要任何网络通信,就是一个本地文本文件,写起来不到二十行代码,但把桌面挂件从“摆件”变成了“有个性的环境反馈器”。同类思路还有很多,比如按编译成功失败切换水体色调,按当日待办完成度调整清洁度,随你发挥。
5.3 长期挂机需要注意的几件事
如果你打算让它 7 乘 24 挂着,有几个点提前处理好会省心很多。一是日志文件要轮转,不然跑一个月能写出几百兆的调试日志;二是存档要有备份轮换,我保留最近三份,按时间覆盖;三是断电后的存档完整性,用写临时文件再原子改名的方式,避免写到一半崩了留下半个损坏的 JSON;四是系统休眠唤醒后的时间跳变,唤醒时lastTick可能差了几小时,走一遍离线补算逻辑就不会出问题。
这一块我自己踩过最狠的坑是日志。当时为了调试方便把每条鱼每次状态切换都打一行,跑了两周后发现磁盘少了十几个 G,一查是这个日志文件。现在默认关闭,只在托盘菜单里手动开一个“调试模式”,开了之后也只打到内存缓冲里,最多保留最近一千条。
最后说点我个人的感受。这类桌面生态项目的价值,其实不在于它有多复杂的技术,而在于它能不能在你不注意的时候安静地存在,在你注意到的时候给你一点微小的变化。我改过它的参数、换过它整套素材、也写过几段外部联动的脚本,最有成就感的一刻不是代码跑通,而是某天下午忙完抬头,发现水变浑了、鱼都缩在角落,然后我顺手清了一下、撒了把食,看着它们重新散开——那种反馈感很难用别的方式替代。如果你也想动手,我的建议是从改一条鱼的数值开始,别急着换整套素材,先感受一下状态和观感之间的对应关系,那个手感比任何文档都值钱。