ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零实现 MiroFish:Canvas 2D 桌面宠物鱼与行为状态机

从零实现 MiroFish:Canvas 2D 桌面宠物鱼与行为状态机 项目标题里只有一个词MiroFish没有仓库、没有截图、没有任何技术说明这反而挺好——它逼着我从零去想这条鱼到底该怎么养。我第一次看到这个名字的时候直觉是把它拆成两半看Miro 有那种微小、极简、克制的意味Fish 就是鱼。合在一起我理解成一个轻量级的桌面虚拟宠物鱼项目——打开电脑屏幕角落有一缸水几条小鱼在里面游你偶尔点一下它会有反应工作的时候它就在那儿安静地摆尾巴。这类东西看着简单真动手做起来却处处是坑鱼为什么看起来像在飘而不是在游为什么游到角落会卡住抽搐为什么三条鱼的时候很流畅、三十条鱼的时候风扇就开始嗷嗷叫。这篇是我自己重做一遍的完整记录。我会讲清楚 MiroFish 这类项目的整体设计思路、行为状态机怎么搭、参数怎么算、打包成桌面应用要注意什么以及我在调试过程中踩过的那些不写进文档的坑。适合两类人看一类是想找个练手小项目、但不想再做待办清单的前端或客户端开发者另一类是做过点动画、想搞清楚自主行为是怎么写出来的朋友。基础要求不高会一点 JavaScript 或者 TypeScript、看得懂循环和向量就够了。1. 项目定位与整体设计思路1.1 先想明白为什么是桌面宠物鱼而不是水族馆模拟器这个决定看起来是产品问题其实直接决定了后面 80% 的代码量。我在开工前纠结过很久最后还是选了桌面宠物这个方向理由是三条。第一条是硬件门槛。如果做成水族馆模拟器你得考虑水质、温度、光照、水草、硝化系统还要做存档和成长曲线。玩家的期待一旦被拉高到经营模拟你不做就是半成品。而桌面宠物的心理预期非常低——它只要在那儿动、看着舒服、不烦人就已经合格了。第二条是性能约束。桌面宠物是常驻进程用户可能开着它写代码写一整天。这意味着内存必须稳、CPU 占用必须低、不能有持续的内存增长。水族馆模拟器那种重逻辑、重 UI 的形态很难做到长时间静默运行。第三条是视觉自洽。鱼缸这个意象天然解决了边界问题——鱼撞到屏幕边缘是 Bug鱼撞到鱼缸壁是正常行为。我把整个可视区域做成一个圆角矩形的水体鱼靠近边缘时会有减速和回避用户看到的是它游到玻璃边上了而不是程序出错了。所以 MiroFish 的第一版定位是一个常驻桌面、透明背景、鱼会自主游动、可以点击互动、不做任何存档和成长系统的轻量程序。砍掉的东西比留下的多我认为这是它能做完的关键。1.2 技术选型为什么最终落在 Canvas 2D 加状态机选渲染方案的时候我试了三条路简单说一下对比方便你按自己的情况选。方案上手成本单条鱼开销上限数量适合场景DOM CSS 动画极低高5~8 条只想做一两条鱼、完全不碰渲染Canvas 2D低低50~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 vitelatest mirofish -- --template vanilla-ts cd mirofish npm install npm run dev目录按 1.3 节的结构铺开。有一个配置我建议一开始就打开——tsconfig.json里的strict: true。行为代码里到处是向量运算null和undefined混进来会非常难查严格模式能在编译期挡掉大部分低级错误。画布初始化要注意高 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.51.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: true加decorations: false是桌面宠物的标准组合出来就是一个无边框透明窗口。有三个坑必须提前知道其一透明窗口在部分系统上不能开硬件加速的全屏混合模式如果出现黑底把backgroundThrottling和透明相关的渲染选项逐个试一遍。其二alwaysOnTop会让窗口盖住所有东西包括全屏演示。建议默认关闭提供快捷键切换或者启动时检测当前是否处于演示状态。其三窗口拖拽要自己实现。无边框窗口默认不能拖需要在顶部区域加一个>// 进入 AvoidEdgedist 120 // 退出 AvoidEdgedist 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 实例之后这个问题再没出现过。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进