MiroFish:轻量桌面水族箱的状态机与离线生态模拟
2026/9/18 18:33:11 网站建设 项目流程

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,一查是这个日志文件。现在默认关闭,只在托盘菜单里手动开一个“调试模式”,开了之后也只打到内存缓冲里,最多保留最近一千条。

最后说点我个人的感受。这类桌面生态项目的价值,其实不在于它有多复杂的技术,而在于它能不能在你不注意的时候安静地存在,在你注意到的时候给你一点微小的变化。我改过它的参数、换过它整套素材、也写过几段外部联动的脚本,最有成就感的一刻不是代码跑通,而是某天下午忙完抬头,发现水变浑了、鱼都缩在角落,然后我顺手清了一下、撒了把食,看着它们重新散开——那种反馈感很难用别的方式替代。如果你也想动手,我的建议是从改一条鱼的数值开始,别急着换整套素材,先感受一下状态和观感之间的对应关系,那个手感比任何文档都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询