从零实现 MiroFish:Canvas 2D 桌面宠物鱼与行为状态机
2026/9/18 10:17:55 网站建设 项目流程

项目标题里只有一个词,MiroFish,没有仓库、没有截图、没有任何技术说明,这反而挺好——它逼着我从零去想"这条鱼到底该怎么养"。我第一次看到这个名字的时候,直觉是把它拆成两半看:Miro 有那种微小、极简、克制的意味,Fish 就是鱼。合在一起,我理解成一个轻量级的桌面虚拟宠物鱼项目——打开电脑,屏幕角落有一缸水,几条小鱼在里面游,你偶尔点一下它会有反应,工作的时候它就在那儿安静地摆尾巴。这类东西看着简单,真动手做起来却处处是坑:鱼为什么看起来像在"飘"而不是在"游",为什么游到角落会卡住抽搐,为什么三条鱼的时候很流畅、三十条鱼的时候风扇就开始嗷嗷叫。

这篇是我自己重做一遍的完整记录。我会讲清楚 MiroFish 这类项目的整体设计思路、行为状态机怎么搭、参数怎么算、打包成桌面应用要注意什么,以及我在调试过程中踩过的那些不写进文档的坑。适合两类人看:一类是想找个练手小项目、但不想再做待办清单的前端或客户端开发者;另一类是做过点动画、想搞清楚"自主行为"是怎么写出来的朋友。基础要求不高,会一点 JavaScript 或者 TypeScript、看得懂循环和向量就够了。

1. 项目定位与整体设计思路

1.1 先想明白:为什么是"桌面宠物鱼"而不是"水族馆模拟器"

这个决定看起来是产品问题,其实直接决定了后面 80% 的代码量。我在开工前纠结过很久,最后还是选了"桌面宠物"这个方向,理由是三条。

第一条是硬件门槛。如果做成"水族馆模拟器",你得考虑水质、温度、光照、水草、硝化系统,还要做存档和成长曲线。玩家的期待一旦被拉高到"经营模拟",你不做就是半成品。而"桌面宠物"的心理预期非常低——它只要在那儿动、看着舒服、不烦人,就已经合格了。

第二条是性能约束。桌面宠物是常驻进程,用户可能开着它写代码写一整天。这意味着内存必须稳、CPU 占用必须低、不能有持续的内存增长。水族馆模拟器那种重逻辑、重 UI 的形态,很难做到长时间静默运行。

第三条是视觉自洽。鱼缸这个意象天然解决了"边界"问题——鱼撞到屏幕边缘是 Bug,鱼撞到鱼缸壁是正常行为。我把整个可视区域做成一个圆角矩形的水体,鱼靠近边缘时会有减速和回避,用户看到的是"它游到玻璃边上了",而不是"程序出错了"。

所以 MiroFish 的第一版定位是:一个常驻桌面、透明背景、鱼会自主游动、可以点击互动、不做任何存档和成长系统的轻量程序。砍掉的东西比留下的多,我认为这是它能做完的关键。

1.2 技术选型:为什么最终落在 Canvas 2D 加状态机

选渲染方案的时候我试了三条路,简单说一下对比,方便你按自己的情况选。

方案上手成本单条鱼开销上限数量适合场景
DOM + CSS 动画极低5~8 条只想做一两条鱼、完全不碰渲染
Canvas 2D50~200 条主力推荐,够用且好调
WebGL / PixiJS中高极低上千条要加水波、焦散、泛光等特效

DOM 方案的问题在于每一条鱼都是一个元素,每帧改transform会触发浏览器的合成层管理,鱼一多就很难受,而且鱼身想做形变基本没戏。WebGL 方案对 MiroFish 这个量级是杀鸡用牛刀,你为了几条鱼去写 shader,调试成本会远大于收益。

Canvas 2D 是这个项目最舒服的位置。鱼身用路径画,摆尾用旋转加缩放模拟,一次drawImage或者一段Path2D就搞定,五十条鱼在普通笔记本上稳稳跑满 60 帧。

行为层我选了有限状态机(FSM),而不是更花哨的行为树或者 GOAP。理由很实际:鱼的行为就那么几种——巡游、转向、被惊扰逃跑、追食物、休息。用行为树是过度设计,用状态机一页纸就能画完状态转移图,出问题的时候一眼能看出是哪条边错了。

1.3 模块划分:让"行为"和"渲染"彻底分开

MiroFish 我拆成四个模块,划分原则是:上游模块不知道下游怎么实现

mirofish/ ├── core/ │ ├── loop.ts # 主循环、固定时间步、帧率统计 │ ├── vec2.ts # 二维向量工具(加法、限长、点积、旋转) │ └── rng.ts # 带种子的随机数,方便复现"鱼的性格" ├── sim/ │ ├── fish.ts # 鱼的实体数据 + 状态机 │ ├── states/ # 每个状态一个文件 │ ├── flock.ts # 分离/对齐/聚合三条规则的合成 │ └── world.ts # 所有鱼、食物粒子、边界 ├── render/ │ ├── canvas.ts # 画布初始化、高 DPI 适配 │ ├── drawFish.ts # 鱼身路径、摆尾动画、颜色渐变 │ └── water.ts # 背景水色、可选气泡层 └── app/ ├── input.ts # 鼠标点击、悬停、拖拽 └── main.ts # 串起来

这个划分带来一个很实在的好处:我可以在没有任何渲染代码的情况下,用纯文本把每条鱼的位置和状态打印到控制台,验证行为逻辑对不对。等行为稳了再接渲染,几乎一次成型。很多人做这类项目卡住,就是因为一上来就把行为逻辑写进了requestAnimationFrame里,渲染和逻辑缠死,改一行动全身。

提示:想验证状态机是不是写对了,先把渲染函数整个注释掉,只跑world.step(dt),每 0.5 秒console.table一次所有鱼的状态和坐标。这一步花十分钟,能省下后面好几小时。

2. 核心机制拆解:鱼怎么才算"活"起来

2.1 有限状态机:让行为有据可依

鱼看起来有"性格",本质上是状态转移条件在起作用。MiroFish 一共定义了五个状态,我用一张转移表把它们串起来:

当前状态触发条件下一个状态说明
Cruise 巡游感知范围内有食物ChaseFood觅食优先级最高
Cruise 巡游距边界 < 安全距离AvoidEdge避免贴边抽搐
Cruise 巡游鼠标点击距离 < 120pxFlee受惊逃跑
ChaseFood 追食食物被吃掉或超出感知范围Cruise回到自由游动
Flee 逃跑距触发点 > 260px 或计时 > 1.8sCalm 平复强制收口,避免无限逃
Calm 平复计时 > 1.2s,速度回落到巡航区间Cruise恢复正常

这里最关键的设计是Flee 和 Calm 的拆分。我最早只写了 Flee,结果发现鱼被点过一次之后会一直保持高速,整缸鱼看起来很躁。加一个平复状态,让速度在 1.2 秒内线性衰减回巡航速度,视觉上就是"鱼被吓了一下,然后慢慢镇定下来",这个细节让整个画面质感提升明显。

另一个要点是状态切换必须带冷却时间。如果不加冷却,鱼在边界附近会疯狂在 Cruise 和 AvoidEdge 之间来回跳,表现出来就是原地抖动。我的做法是给每个实体挂一个stateLockUntil时间戳,任何状态切换后至少锁定 0.25 秒。

// sim/fish.ts export const enum FishState { Cruise, ChaseFood, AvoidEdge, Flee, Calm } export interface Fish { id: number; pos: { x: number; y: number }; vel: { x: number; y: number }; heading: number; // 朝向,弧度 speed: number; // 当前标量速度 px/s maxSpeed: number; // 该鱼的巡航上限 state: FishState; stateLockUntil: number; stateTimer: number; // 性格参数,出生时随机生成后固定 wander: number; // 巡游随机扰动强度 timid: number; // 胆小程度,影响逃跑触发半径 } export function trySetState(f: Fish, next: FishState, now: number) { if (now < f.stateLockUntil) return false; f.state = next; f.stateTimer = 0; f.stateLockUntil = now + 0.25; // 250ms 冷却 return true; }

2.2 转向与游动:为什么不能用位置插值

很多人做动画的第一反应是"给鱼一个目标点,让它线性插值过去"。这个写法在 MiroFish 里绝对行不通——鱼会走直线,看起来像一颗鱼形的子弹,转角会瞬间折向,非常假。

真实感来自两个约束:转向有角速度上限速度有变化惯性

角度上限的意思是,鱼不能在一帧内把朝向从 0 转到 180 度,它必须一帧转一点点。我用的参数是最大角速度MAX_TURN_RATE = 2.5 rad/s。算一下:180 度是 π ≈ 3.14 弧度,3.14 / 2.5 ≈ 1.26 秒。也就是说鱼掉个头要一秒多,这个速度看起来刚刚好,慢了像在漂,快了像抽搐。

速度惯性则用一阶滞后,简单说就是"当前速度朝目标速度靠,但每帧只靠过去一部分":

// 速度趋近:rate 越大越灵敏 function approach(current: number, target: number, rate: number, dt: number) { const delta = target - current; const step = rate * dt; if (Math.abs(delta) <= step) return target; return current + Math.sign(delta) * step; } // 朝向趋近,注意处理 -π/π 环绕 function approachAngle(cur: number, target: number, rate: number, dt: number) { let delta = target - cur; while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; const step = rate * dt; if (Math.abs(delta) <= step) return target; return cur + Math.sign(delta) * step; }

踩过的坑在这里:角度环绕。你直接用target - cur去算差值,当鱼朝向 -3.0、目标是 +3.0 的时候,数学上差 6.0 弧度,鱼会选择"转一大圈"而不是"从另一侧转过去"。表现就是鱼偶尔会莫名其妙原地打个大圈。必须把差值规整到 [-π, π],这个while循环虽然朴素但非常可靠。

2.3 群游行为:Boids 三规则的最小实现

三条鱼以上就要考虑群游了。经典的 Boids 有三条规则:分离(别贴太近)、对齐(朝向跟邻居一致)、聚合(别掉队)。我不建议直接抄网上的完整实现,因为那些实现通常带一大堆邻居搜索优化,对几十条鱼的规模是纯负担。

MiroFish 的做法很直接:不做空间划分,每帧 O(n²) 全量比较。50 条鱼是 2500 次比较,现代浏览器一帧处理这个量毫无压力。超过 200 条再考虑网格分区。

权重我调了很久,最终值是:

规则权重感知半径作用
分离1.642px最高优先级,避免重叠
对齐0.8110px让鱼群方向趋同
聚合0.6160px保持队形松散聚合
随机游走0.35-避免死板直线
边界回避2.2120px越靠近边缘越强

分离权重给到 1.6 是因为我实测发现,只要分离系数低于 1.2,鱼群就会开始互相穿插,看起来像纸片叠在一起。给到 1.6 之后重叠概率降到接近零。

边界回避用的是平滑力,不是硬转向:距离边界 120px 开始产生一个指向内侧的力,距离越近力越大,到 30px 时达到最大。这样鱼会呈现"提前减速、缓缓转身"的效果,而不是撞上了才急转。

// sim/flock.ts function edgeAvoid(f: Fish, w: number, h: number, margin = 120) { const fx = f.pos.x < margin ? (1 - f.pos.x / margin) : f.pos.x > w - margin ? -(1 - (w - f.pos.x) / margin) : 0; const fy = f.pos.y < margin ? (1 - f.pos.y / margin) : f.pos.y > h - margin ? -(1 - (h - f.pos.y) / margin) : 0; return { x: fx * 2.2, y: fy * 2.2 }; }

注意:这里的力是"权重"不是"加速度",最终要乘一个统一系数再限长。别直接把它加到速度上,否则鱼在角落会被弹飞出去,像弹珠一样。

3. 实操过程:从空画布到一缸会动的鱼

3.1 环境搭建:三行命令起步

技术栈我选 Vite + TypeScript,没别的,就是启动快、热更新稳、零配置。

npm create vite@latest mirofish -- --template vanilla-ts cd mirofish && npm install npm run dev

目录按 1.3 节的结构铺开。有一个配置我建议一开始就打开——tsconfig.json里的strict: true。行为代码里到处是向量运算,nullundefined混进来会非常难查,严格模式能在编译期挡掉大部分低级错误。

画布初始化要注意高 DPI 适配,这是新手最容易忽略的一步。不做适配的话,在 2 倍屏上整缸鱼都是糊的。

// render/canvas.ts export function setupCanvas(canvas: HTMLCanvasElement, cssW: number, cssH: number) { const dpr = Math.min(window.devicePixelRatio || 1, 2); // 上限 2,避免 4K 屏爆显存 canvas.width = Math.floor(cssW * dpr); canvas.height = Math.floor(cssH * dpr); canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; const ctx = canvas.getContext('2d', { alpha: true })!; ctx.scale(dpr, dpr); return ctx; }

dpr我做了上限 2 的处理。之前在某台高分辨率设备上跑,devicePixelRatio是 3,画布实际尺寸直接飙到三倍,帧率掉了一半,而画面精细度的提升肉眼几乎看不出来。

3.2 主循环:固定时间步是稳定性的关键

MiroFish 用了固定时间步 + 累加器的写法,而不是直接把每帧的deltaTime传进物理计算。原因是显示器刷新率五花八门,60Hz、120Hz、144Hz 都有,如果直接用真实 delta,同一套参数在不同设备上表现完全不同——120Hz 的机器上鱼会游得明显更快。

// core/loop.ts const FIXED_DT = 1 / 60; // 逻辑固定 60 步/秒 const MAX_FRAME = 0.25; // 单帧最大补偿,防止切回标签页时爆炸 let accumulator = 0; let last = performance.now() / 1000; export function startLoop(step: (dt: number) => void, render: (alpha: number, dt: number) => void) { function frame(nowMs: number) { const now = nowMs / 1000; let frameTime = now - last; last = now; if (frameTime > MAX_FRAME) frameTime = MAX_FRAME; // 关键保护 accumulator += frameTime; while (accumulator >= FIXED_DT) { step(FIXED_DT); accumulator -= FIXED_DT; } render(accumulator / FIXED_DT, FIXED_DT); requestAnimationFrame(frame); } requestAnimationFrame(frame); }

MAX_FRAME = 0.25这一行我是被坑过才加的。用户切到别的标签页待了两分钟再切回来,performance.now()的差值会是一百多秒,累加器会产生六千多次step调用,页面直接卡死好几秒。把它钳在 0.25 秒,最多补 15 步,用户完全感知不到。

render收到的alpha是插值系数,用来在两次逻辑更新之间做视觉插值。不做这个的话,60Hz 逻辑配 144Hz 屏幕会出现规律的轻微抖动。做起来也简单,就是渲染时用prevPos + (pos - prevPos) * alpha

3.3 参数计算:速度、半径、数量怎么定

这部分是我觉得最值得分享的,因为参数不是拍脑袋来的,基本都能算。

巡航速度。画布按 1600×900 考虑,如果速度取 75 px/s,横穿屏幕需要 1600 / 75 ≈ 21 秒,太慢,看着像标本。取 150 px/s 是 10.7 秒,取 180 px/s 是 8.9 秒。实测 150~180 这个区间最舒服,鱼身长的比例也好看:假设鱼身长 48px,速度 160 px/s,那就是每秒游过约 3.3 个身长,这个比例在鱼类里属于悠闲巡游的状态。

逃跑速度。给到巡航速度的 2.6 倍左右,即 160 × 2.6 ≈ 415 px/s。再高视觉上会糊,因为一帧才 2.7px 位移,但速度感主要来自尾摆频率,所以我把尾摆频率也跟着速度映射,从 2.2 Hz 提到 6 Hz。速度感的八成来自这个频率变化,而不是位移本身,这一点是做完之后才意识到的。

感知半径。这个跟屏幕尺寸挂钩,取短边的 12% 左右比较合理。900 × 0.12 = 108px,跟前面 Boids 的 110px 对上了。

鱼的数量。我做过一轮压力测试,结果大致是:

数量平均帧时间备注
8 条0.9 ms完全无压力
30 条2.4 ms舒适区上限
60 条5.1 ms可接受,风扇开始有动静
120 条11.8 ms开始掉帧

60 帧的预算是 16.6ms,扣掉浏览器自身开销,逻辑加渲染控制在 8ms 以内比较稳妥,所以 30~50 条是 MiroFish 的合理区间。我默认给了 12 条,用户可以在设置里调,上限锁 60。

3.4 行为状态的具体实现

拿 Flee 举例,因为它最能体现"多处协同"的设计。

// sim/states/flee.ts export function updateFlee(f: Fish, ctx: WorldCtx, dt: number) { f.stateTimer += dt; // 1. 逃离方向 = 远离触发点 const dx = f.pos.x - ctx.threat.x; const dy = f.pos.y - ctx.threat.y; const len = Math.hypot(dx, dy) || 1; const targetHeading = Math.atan2(dy, dx); // 2. 目标速度按剩余距离衰减,避免一直高速 const decay = Math.min(1, ctx.threat.dist / 260); const targetSpeed = f.maxSpeed * (1.2 + 1.4 * decay); f.heading = approachAngle(f.heading, targetHeading, 6.0, dt); f.speed = approach(f.speed, targetSpeed, 900, dt); // 3. 出圈就切平复 if (ctx.threat.dist > 260 || f.stateTimer > 1.8) { trySetState(f, FishState.Calm, ctx.now); } }

三个细节值得说:

第一,逃跑时的转向率给到 6.0 rad/s,是巡航时的两倍多。鱼受惊时转向会突然变急,这个"急"就是靠转向率体现的,比单纯提高速度更有效。

第二,targetSpeed里的1.2 + 1.4 * decay让鱼在离威胁近的时候冲得最快,远了就慢下来。如果用固定速度,鱼会以最高速跑满 1.8 秒然后突然减速,很不自然。

第三,双出口条件。距离和时间两个条件满足任意一个就退出,防的是"鱼被逼到角落,距离永远出不了 260px"导致的死循环。

Calm 状态反过来做就行,速度向maxSpeed回落,转向率降回 2.5,1.2 秒后回 Cruise。

3.5 交互层:喂食、点击、跟随

交互是让用户愿意多看两眼的唯一理由。MiroFish 做了三个:

点击惊扰。鼠标按下时记录位置和时间,凡是距离小于120 × timid的鱼进入 Flee。timid是每条鱼出生时随机的 0.7~1.4 倍系数,这样同一缸鱼里有的胆大有的胆小,画面立刻就有层次了。

投喂食物。右键落下一颗食物粒子,初速向下 40 px/s,带轻微左右摇摆。鱼在 200px 感知范围内切到 ChaseFood,朝食物游。食物被吃掉的判定用距离 14px,吃掉后鱼进 Calm 状态,同时食物数量减一,上限 8 颗防止用户狂点。

// 食物更新,很简单的自由落体 + 到底消失 function updateFood(food: Food, dt: number, h: number) { food.vy += 12 * dt; // 轻微重力 food.pos.y += food.vy * dt; food.pos.x += Math.sin(food.pos.y * 0.02) * 8 * dt; // 水中摇摆 if (food.pos.y > h - 10) food.alive = false; }

鼠标跟随。这个我犹豫了很久要不要加,因为做不好会显得很廉价——所有鱼一窝蜂跟着鼠标,像一群被绳子牵着的狗。最后的做法是:只有进入 180px 范围的鱼会产生一个很弱的趋近力,权重只有 0.4,而且不会切换状态,就是在 Cruise 里加一点偏向。效果是"有几条鱼好像对你感兴趣,凑过来看了看又走了",这个度比较合适。

3.6 打包成桌面应用:壳怎么选

浏览器里跑得再好,它也是个网页。要常驻桌面,得套壳。

Electron 和 Tauri 我都试了。Electron 生态成熟、坑少,但空壳包大约 150MB 起步,内存占用也要 100MB 以上;Tauri 用系统 WebView,包体 5MB 以内,内存占用低不少,代价是各平台 WebView 版本不统一,遇到渲染差异要单独排查。

MiroFish 这种绘制量不大的项目,我推荐 Tauri。几个关键配置:

{ "app": { "windows": [ { "width": 900, "height": 520, "transparent": true, "decorations": false, "alwaysOnTop": true, "skipTaskbar": false, "resizable": true } ] } }

transparent: truedecorations: false是桌面宠物的标准组合,出来就是一个无边框透明窗口。有三个坑必须提前知道:

其一,透明窗口在部分系统上不能开硬件加速的全屏混合模式,如果出现黑底,把backgroundThrottling和透明相关的渲染选项逐个试一遍。

其二,alwaysOnTop会让窗口盖住所有东西,包括全屏演示。建议默认关闭,提供快捷键切换,或者启动时检测当前是否处于演示状态。

其三,窗口拖拽要自己实现。无边框窗口默认不能拖,需要在顶部区域加一个>// 进入 AvoidEdge:dist < 120 // 退出 AvoidEdge:dist > 165 <- 留出 45px 的缓冲区 if (f.state === FishState.AvoidEdge) { if (distToEdge > 165) trySetState(f, FishState.Cruise, now); } else { if (distToEdge < 120) trySetState(f, FishState.AvoidEdge, now); }

再叠加上前面说的 0.25 秒状态锁,这个问题基本绝迹。滞回这个思路在行为系统里到处都是,食物判定、逃跑判定、跟随判定都得用,值得记成肌肉记忆。

4.2 内存缓慢增长:多半是粒子没回收

跑了一晚上之后内存从 80MB 涨到 400MB,这个我在早期版本遇到过。

原因是我当时用数组加splice管理气泡粒子,每帧生成几个、删除几个,splice会移动数组元素,频繁调用会让 V8 的垃圾回收跟不上,产生大量短命对象。

改法是对象池:预分配固定长度的数组,用一个alive标志标记存活,复用时从池里找第一个alive === false的位置覆盖。

class BubblePool { private items: Bubble[] = []; private cursor = 0; constructor(size = 200) { for (let i = 0; i < size; i++) { this.items.push({ x: 0, y: 0, vy: 0, r: 0, alive: false }); } } spawn(x: number, y: number) { // 环形扫描,最多找一圈 for (let i = 0; i < this.items.length; i++) { const idx = (this.cursor + i) % this.items.length; if (!this.items[idx].alive) { Object.assign(this.items[idx], { x, y, vy: -30, r: 2 + Math.random() * 2, alive: true }); this.cursor = idx + 1; return; } } } update(dt: number) { for (const b of this.items) { if (!b.alive) continue; b.y += b.vy * dt; if (b.y < -10) b.alive = false; } } }

环形扫描的写法比每次从 0 开始找要均匀,不会让前面的槽位被反复覆盖、后面的永远用不到。

4.3 拖到副屏之后鱼不见了

多显示器场景下的坐标问题挺隐蔽的。用户把窗口从主屏拖到副屏,窗口尺寸变了,但我的world.width还是启动时的那份,结果鱼全都在画布外面游。

解决靠监听ResizeObserver,并且做两件事:一是更新世界尺寸,二是把越界的鱼拉回来

const ro = new ResizeObserver((entries) => { const r = entries[0].contentRect; world.width = r.width; world.height = r.height; ctx = setupCanvas(canvas, r.width, r.height); // 重建画布尺寸 // 把跑到界外的鱼夹回可视区 for (const f of world.fishes) { f.pos.x = Math.min(Math.max(f.pos.x, 20), world.width - 20); f.pos.y = Math.min(Math.max(f.pos.y, 20), world.height - 20); } }); ro.observe(canvas);

这里有个细节:setupCanvas里调了ctx.scale(dpr, dpr),如果每次 resize 都在同一个 context 上重复调用,缩放会累加,画布越缩越小。所以要么重建 canvas 元素,要么在 scale 之前先ctx.setTransform(1, 0, 0, 1, 0, 0)重置。我选了后者,一条语句解决。

4.4 常见问题速查表

现象最可能的原因处理方式
鱼在边缘高频摆动状态阈值互斥导致抖动加滞回区间 + 状态切换冷却
鱼偶尔原地转大圈角度差未规整到 [-π, π]approachAngle处理环绕
高刷屏上鱼游得更快直接用了真实 deltaTime改固定时间步 + 累加器
切回标签页卡住几秒累加器补偿过多帧单帧时间钳制在 0.25s
画面整体模糊未做 devicePixelRatio 适配按 dpr 放大画布并重置变换
长时间运行内存上涨粒子频繁增删改用对象池 + alive 标记
透明窗口显示为黑底系统合成与透明冲突逐项排查渲染相关窗口配置
拖到副屏后元素消失世界尺寸未随窗口更新ResizeObserver + 边界夹取
鱼群互相穿插分离权重过低分离系数提到 1.4 以上
所有鱼动作完全同步随机游走被共用每条鱼独立 RNG 种子

5. 一些可以继续往下做的方向和我自己的体会

MiroFish 现在的样子是个能用的基础版本,但它离"好玩"还有点距离。我列几个自己打算做的扩展,按性价比排序。

最有性价比的是"鱼的性格记忆"。每一条鱼出生时随机一组性格参数(活跃度、胆小度、贪吃度、社交倾向),并且把这组参数存到本地。用户下次打开,还是那几条熟悉的鱼,那条特别胆小的还是会躲鼠标,那条贪吃的还是会第一个冲过来。这个改动代码量不大,但对"养成感"的提升是断崖式的——用户会开始给鱼起名字。

其次是昼夜节奏。根据系统时间调整水体的色调和鱼的活动强度,白天明亮活跃,深夜偏暗、游动变慢、偶尔有鱼沉到底部静止。这个不做任何交互,纯视觉变化,但会让人觉得"这缸鱼是有生命的",而不是一个无限循环的 GIF。

再往后是水波和焦散效果。这块就得考虑上 WebGL 了,Canvas 2D 硬做性能吃不消。我的想法是把水体背景单独用一个 WebGL canvas 渲染,鱼还在 2D 层,两层叠起来。复杂度不低,先放着。

跨窗口跟随也想过,但涉及系统级窗口管理接口,各平台差异太大,投入产出比不划算,放弃了。

最后说说几个我认为比代码更重要的判断。

一个是先做减法。我做第一版的时候砍掉了成长系统、金币、商店、成就,只留下"鱼会游、能互动"。做完之后发现,其实还能再砍——鼠标跟随那一版我就应该砍掉,它是最后一个加上去的,也是调试最久的。小项目的失败基本都不是功能少,而是做不完。

另一个是参数集中管理。我吃过这个亏,早期参数散落在七八个文件里,调一次手感要翻半天。后来统一抽到一个config.ts,所有数值带注释说明范围,调参效率至少快三倍。你如果是新手,第一天就把这个文件建起来,哪怕里面只有三行。

还有一点是关于**"感觉"的调试方法**。行为参数这种东西,光看数值判断不了好坏,必须连续看。我的做法是录一段六十秒的屏幕,用两倍速看,凡是出现"重复感"或者"呆滞感"的地方暂停,去翻对应时刻的状态日志。这个方法帮我抓到了好几个只在长时间运行后才暴露的问题,比如鱼在游了四五分钟之后会集体聚集到某个角落——根因是随机游走的种子被复用了,所有鱼的扰动序列一样,时间长了就同步了。给每条鱼独立一个 RNG 实例之后,这个问题再没出现过。

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

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

立即咨询