ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MiroFish:轻量桌面水族箱的状态机与离线生态模拟

2026/9/18 18:33:21 拓冰建站 浏览量
MiroFish:轻量桌面水族箱的状态机与离线生态模拟 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一查是这个日志文件。现在默认关闭只在托盘菜单里手动开一个“调试模式”开了之后也只打到内存缓冲里最多保留最近一千条。最后说点我个人的感受。这类桌面生态项目的价值其实不在于它有多复杂的技术而在于它能不能在你不注意的时候安静地存在在你注意到的时候给你一点微小的变化。我改过它的参数、换过它整套素材、也写过几段外部联动的脚本最有成就感的一刻不是代码跑通而是某天下午忙完抬头发现水变浑了、鱼都缩在角落然后我顺手清了一下、撒了把食看着它们重新散开——那种反馈感很难用别的方式替代。如果你也想动手我的建议是从改一条鱼的数值开始别急着换整套素材先感受一下状态和观感之间的对应关系那个手感比任何文档都值钱。