
1. 项目核心思路拆解1.1 玩法机制的本质先说说这个项目的来头。很多做游戏开发或者前端练手的朋友大概率都见过这个名字——《大鱼吃小鱼》。它几乎和《贪吃蛇》一样是编程学习路径上绕不开的经典小游戏。我第一次接触这个项目是在一个游戏开发基础课的作业列表里编号刚好排在34位当时对它的初印象是“玩法简单”但真正动手做起来才发现它背后藏着的逻辑设计、碰撞检测、手感调优几乎覆盖了一个小型游戏从零到可玩的全过程。先说清楚它到底是什么。在屏幕上玩家控制的是一条小鱼场景里随机游荡着大大小小的其他鱼类。规则只有一条你只能吃掉比你小的鱼一旦碰到比你大的游戏结束。每吃一条鱼你的体型就变大一点能吃的东西也越来越多但也意味着你更容易被更大的鱼盯上。这个“大越小 · 小越大”的循环就是整个游戏的核心张力。很多人一开始会把它理解成“做一个会动的小鱼就行”这是个误区。真正让《大鱼吃小鱼》有意思的是它的成长反馈机制体型变大不只是视觉变化更会直接影响移动速度、可碰撞范围、难度曲线甚至关卡平衡。如果只做一个“贴图移动 简单碰撞”的Demo那它充其量是个动画练习而不是游戏。作为项目它适合谁来做我的判断是刚学完语法、想尝试第一个完整项目的编程初学者或者做前端开发、想用Canvas或PixiJS练手的人再或者是对游戏策划感兴趣、想研究“简单规则如何产生可玩性”的爱好者。它不大不小恰好能让你在一两周业余时间里走完“设计-编码-调优-迭代”的完整链路。1.2 为什么选这个方案而不是别的市面上《大鱼吃小鱼》的实现方案有不少有人用Python的Pygame写有人用Unity做还有人用纯JavaScript搞DOM操作。我这次要拆解的是基于HTML5 Canvas JavaScript的方案理由很简单首先零依赖。不用装引擎、不用配环境浏览器打开就是游戏方便调试也方便给别人展示。对一个练手项目来说能不能“随手跑起来”很重要而Canvas方案天然满足这一点。其次Canvas的渲染模式特别适合这类“大量动态物体”的场景。每条鱼都是一个独立的运动对象它们不断移动、旋转、碰撞使用Canvas的requestAnimationFrame驱动重绘性能上完全没有压力。而且它逼着你手动管理游戏循环、对象生命周期、碰撞检测这些底层逻辑做完一遍对整个游戏引擎是怎么回事会有非常直观的认识。再来是手感调优。DOM方案很难做出平滑的鼠标跟随和速度衰减Unity又太重Canvas在两者之间找到了恰好合适的平衡。它让你能精确地控制每一帧里角色的位置、速度、方向的插值计算这在后面讲到手感优化的时候会体现得很明显。我见过有人一上来就套PixiJS或者Phaser这种专业框架反而容易迷失——框架帮你屏蔽了细节你也失去了理解“游戏循环到底在循环什么”的机会。作为项目拆解我更推荐先裸写一套Canvas逻辑跑通了再考虑框架化。2. 核心逻辑与关键技术点2.1 角色移动与控制从“跟手”到“有惯性”《大鱼吃小鱼》的操作模式和《球球大作战》很像是鼠标控制角色移动。但“鼠标在哪鱼就在哪”只是最粗糙的实现。真正做起来需要考虑两个关键因素跟随速度和惯性。先说跟随速度。如果鱼的位置直接等于鼠标位置那画面会显得非常死板——鱼像被焊死在光标上没有一点生物感。我当时的做法是用每一帧去计算鱼当前位置与鼠标位置的距离然后按比例移动一段距离。核心公式长这样let dx mouse.x - player.x; let dy mouse.y - player.y; player.x dx * 0.08; player.y dy * 0.08;这个0.08就是跟随系数数值越大跟得越紧越小越拖沓。实测下来0.06~0.1之间是比较舒服的范围。小于0.05会显得鱼反应迟钝大于0.15则容易产生“甩尾”的抖动感。这个系数在调优阶段非常值得花时间它是“手感”的基石。再说惯性。纯“位置插值”的跟随方案有个问题鱼在任何情况下都没有自己的运动节奏看久了会显得很假。所以我加了速度向量let speed 0.15; player.vx (mouse.x - player.x) * speed; player.vy (mouse.y - player.y) * speed; player.x player.vx; player.y player.vy;这样鱼就有了“速度”这个中间态停止移动时会因为速度向量逐渐归零而产生自然的滑动而不是瞬间停在原地。滑动的尾感非常微妙做得好能明显提升手感层级。这里有个很关键的知识点速度衰减不要用线性衰减vx - vx * 0.1这种而是用指数衰减vx * 0.92。线性衰减会让人感觉“刹车很死”指数衰减则更接近真实水中的阻力感画面自然度完全不同。顺带说一个容易忽略的点鱼的朝向。很多新手做完移动才发现鱼是“横着飘”的没有头尾之分。解决方法是计算移动方向的角度然后用Math.atan2求出朝向再旋转贴图let angle Math.atan2(player.vy, player.vx); ctx.rotate(angle);这是个很小的细节但对游戏整体观感的提升是巨大的。自己玩的时候可能觉得无所谓但如果你把这个项目放到作品集里有没有朝向处理一眼就能看出做没做过游戏。2.2 碰撞检测与“吃”的判定不只是“碰到就消失”碰撞检测是这个游戏的核心逻辑之一但很多人会用一种很“暴力”的方式判断两个圆形是否相交相交就判定吃掉或死亡。功能上没错但手感很粗糙。先说为什么用圆形碰撞而不是矩形碰撞。鱼的形象虽然有鳍有尾但从碰撞公平性的角度看圆形是最接近玩家心理预期的碰撞体。矩形会出现“我明明没碰到鱼头却被判定死亡”的情况圆形则要宽容得多。圆形碰撞检测公式是function isColliding(a, b) { let dx a.x - b.x; let dy a.y - b.y; let distance Math.sqrt(dx * dx dy * dy); return distance a.radius b.radius; }这里引入了一个关键概念鱼的radius碰撞半径。它不一定要等于贴图的实际半径而是一个独立的逻辑值。通常我会把碰撞半径设置成视觉大小的80%左右这样玩家会感觉碰撞判定的“框”贴着鱼身但又不会太苛刻。这是做动作类游戏时很常见的“宽容度”处理碰撞稍小一点玩家会觉得是自己的操作好碰撞稍大一点玩家会觉得是游戏不公平。再来说“吃”的判定。注意光碰撞还不够还要比较大小。这个项目里鱼的大小通常用一个size属性表示同时影响radius和贴图缩放。当碰撞发生时function onCollision(player, other) { if (player.size other.size) { player.size other.size * 0.2; // 成长 player.radius Math.sqrt(player.size) * 4; // 同步碰撞半径 score other.size; removeFish(other); playEatSound(); } else { gameOver(); } }这段代码里有三个注重细节的地方第一成长量用other.size * 0.2而不是固定值。这样做的好处是吃大鱼长得快吃小鱼长得慢成长曲线更有节奏感。如果吃任何鱼都加固定值到游戏后期你会发现吃半天小鱼一点变化都没有挫败感极强。第二碰撞半径和size的关系不是线性的而是用Math.sqrt(player.size)做映射。为什么因为面积和半径是平方关系如果size代表“质量”那么半径应该和质量的开平方成正比。你用线性映射radius size * 常数会出现“鱼看起来没那么大碰撞体积却大得离谱”的视觉偏差。第三这里用了playEatSound()这样的占位函数。别看它简单这其实是游戏反馈系统的一部分没有音效的吃鱼是“闷”的玩家感受不到成长的愉悦。哪怕只是用Web Audio API生成一个很短的“啵”声体验都会好很多。2.3 玩家成长与难度曲线控制“爽感”和“压迫感”的节奏这个项目里最容易被忽视、但也是拉开“好玩”和“能玩”差距的就是难度曲线。很多人做到后面要么觉得游戏玩一会儿就无聊了要么觉得一开始就被大鱼追得毫无还手之力。这本质上是成长曲线和AI刷新策略出了问题。先说AI鱼的生成逻辑。比较粗糙的做法是随机在地图任意位置生成任意大小的鱼。但这会出现一个问题你刚出生时可能身边围着一圈比你大的鱼怎么玩都活不过十秒或者你长到很大的时候整池子鱼都比你还小接下来就变得毫无挑战。我的做法是引入“区域大小分布”概念。把鱼池按离中心点的距离划分成不同危险等级靠近中心的区域鱼的尺寸偏小和玩家初始大小接近越靠边缘鱼的尺寸越大刷新频率也越高。这样玩家可以通过“待在中间发育去边缘冒险”来主动控制风险。这和《绝地求生》里“安全区”的设计逻辑如出一辙虽然是小小的休闲游戏却能让玩家在潜意识里做决策产生策略感。其次是刷新数量与玩家体型的动态适配。我设定了一个“总量守恒”策略let totalSize 0; let spawnThreshold 400 - player.size * 0.2; if (totalSize spawnThreshold) spawnFish();池子里所有鱼的总尺寸每个鱼的size累加维持在某个范围内且这个范围会随着玩家变大而缩小。意思就是你越长越大池子里能容纳的资源变少大鱼的比例自然升高压迫感随之而来。这是非常典型的环境压力递增设计既不会让玩家觉得“突然变难”又保持挑战感。再来说说成长上限。如果不做任何限制玩家会很快长到全屏无敌丧失目标。我当时加了一个“大鲨鱼”机制每隔一定时间刷新一条远超玩家当前大小的巨型鱼从屏幕边缘游过。它的移动轨迹不一定要追着玩家走但它的存在本身就是威慑——提醒你不要在一个位置停留太久。这个设计让游戏永远处于“我们不是最强”的状态玩家需要持续移动、持续进食、持续观察环境整个游戏的紧张感一下子就立起来了。3. 实操实现从零搭建一个可玩版本3.1 准备工作画布、坐标系与基础渲染在动手写逻辑之前先把基础环境搭好。我这里用一个干净的HTML文件引入Canvas定义游戏全局常量。!DOCTYPE html html head style body { margin: 0; background: #0a2a3a; overflow: hidden; } canvas { display: block; } /style /head body canvas idgameCanvas/canvas script const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); canvas.width 800; canvas.height 600; /script /body /html这里有几个细节说一下。Canvas尺寸我建议固定成800x600而不是直接占满全屏。为什么固定尺寸开发起来方便逻辑简单所有坐标都在0~800、0~600的范围内计算后期如果想适配不同屏幕再做view缩放即可不影响核心逻辑。然后建立“游戏循环”用requestAnimationFrame驱动每一帧更新和渲染。核心结构是这样的let lastTime 0; function gameLoop(timestamp) { let dt (timestamp - lastTime) / 1000; lastTime timestamp; update(dt); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);dt是上一帧到这一帧的间隔时间秒它非常重要。如果你的更新逻辑里包含任何随时间的运动比如鱼自己游动、速度衰减都必须乘以dt否则在帧率不同的设备上游戏速度会天差地别。做这个项目的时候我见过有人直接写fish.x fish.vx在他自己的60帧电脑上正常换到144Hz显示器上就像开了加速器。用dt统一时间尺度是专业和业余的一个明显分界。3.2 鱼对象的生命周期管理接下来定义鱼的数据结构。我建议用统一的fish对象通过一个type字段区分玩家和AIlet fishList []; let player { type: player, x: 400, y: 300, size: 10, vx: 0, vy: 0, color: #ffcc00, radius: 10, isPlayer: true }; fishList.push(player); function spawnFish() { let side Math.random() 0.5 ? left : right; let x, y; if (side left) { x -30; y Math.random() * canvas.height; } else { x canvas.width 30; y Math.random() * canvas.height; } let size 5 Math.random() * 15; let speed 30 Math.random() * 40; let vx side left ? speed : -speed; let vy (Math.random() - 0.5) * 20; fishList.push({ type: ai, x, y, size, vx, vy, radius: Math.sqrt(size) * 4, color: randomColor() }); }注意到一个设计点AI鱼的出生位置在屏幕外x为-30或830然后游进屏幕。这样避免“鱼凭空刷新在玩家脸上”的尴尬瞬间也给了玩家观察和反应的时间。这里也体现了上文中提到的“半径与size的平方根关系”。注意AI鱼自己也有radius这就是它自己的碰撞直径。每个鱼对象自己管好自己的radius属性而不是在每次碰撞时临时计算减少运行时的计算量也让代码更清晰。3.3 核心循环逻辑的实现现在进入重点完整实现update和render。update(dt)负责四件事更新玩家位置、更新AI鱼位置、检查碰撞并处理、刷新新鱼。function update(dt) { // 1. 玩家位置更新跟随鼠标惯性 const mouseSpeed 5; let dx mouse.x - player.x; let dy mouse.y - player.y; player.vx dx * 3; player.vy dy * 3; player.vx * 0.92; // 指数衰减 player.vy * 0.92; player.x player.vx * dt * 30; player.y player.vy * dt * 30; // 边界锁定 player.x Math.max(10, Math.min(canvas.width - 10, player.x)); player.y Math.max(10, Math.min(canvas.height - 10, player.y)); // 2. AI鱼移动 for (let fish of fishList) { if (fish.isPlayer) continue; fish.x fish.vx * dt; fish.y fish.vy * dt; } // 3. 碰撞检测 for (let i fishList.length - 1; i 0; i--) { let fish fishList[i]; if (fish.isPlayer) continue; if (isColliding(player, fish)) { if (player.size fish.size) { player.size fish.size * 0.2; player.radius Math.sqrt(player.size) * 4; fishList.splice(i, 1); } else { gameOver(); return; } } } // 4. 刷新AI if (fishList.length 20) spawnFish(); // 5. 更新分数、播放音效等 }render()里做的事稍微多但逻辑很直白清屏、绘制背景、绘制AI鱼、绘制玩家、绘制分数UI。function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制背景水波 ctx.fillStyle #0a3a4a; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制AI鱼 for (let fish of fishList) { if (fish.isPlayer) continue; drawFish(fish); } // 绘制玩家 drawFish(player); // 分数 ctx.fillStyle #fff; ctx.font 20px Arial; ctx.fillText(Score: score, 20, 40); }drawFish函数比较简单但有个地方要注意鱼的朝向。绘制前的旋转要基于鱼自己的移动方向而不是固定向右。否则鱼会非常生硬地“平移”。function drawFish(fish) { ctx.save(); ctx.translate(fish.x, fish.y); if (fish.vx ! 0 || fish.vy ! 0) { let angle Math.atan2(fish.vy, fish.vx); ctx.rotate(angle); } ctx.scale(fish.size / 10, fish.size / 10); ctx.fillStyle fish.color; ctx.beginPath(); ctx.ellipse(0, 0, 20, 12, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }这里用ellipse绘制鱼身用scale根据size缩放大小。注意我固定了绘制基准大小是20x12然后通过fish.size / 10来控制缩放这样一个size10的鱼正好绘制成20x12size20的鱼就放大两倍。这种“用基准尺寸scale”的方式比直接用变量控制长宽更简洁也更容易统一管理。3.4 鼠标事件与状态管理最后补上鼠标处理和游戏状态。鼠标坐标随时更新但需要注意坐标系偏移——如果Canvas被CSS缩放或者有边距直接读e.clientX会不准。canvas.addEventListener(mousemove, (e) { let rect canvas.getBoundingClientRect(); mouse.x e.clientX - rect.left; mouse.y e.clientY - rect.top; });为什么用getBoundingClientRect而不是直接读e.offsetX因为offsetX在某些浏览器里是相对内边距的用起来不可靠。rect.left直接拿到Canvas在页面上的实际偏移算出来的坐标永远精确。游戏状态我用一个简单的gameState变量管理共三种playing、dead、win。有人会问一个休闲游戏要不要这么复杂我的经验是哪怕再小的游戏也建议一开始就设计状态机不然你后面加“暂停”“复活”“结算界面”的时候会发现自己改得一头雾水。状态机是代码的可扩展性保障和游戏规模无关。let gameState playing; let score 0; function gameOver() { gameState dead; ctx.fillStyle rgba(0,0,0,0.6); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #fff; ctx.font 30px Arial; ctx.fillText(Game Over - Score: score, 250, 300); }4. 常见问题与排查技巧实录4.1 鱼的移动“发飘”或“卡顿”怎么调做《大鱼吃小鱼》的时候我前前后后调手感至少花了三个晚上最后总结出一个核心问题所有移动问题几乎都是因为“时间单位”没统一或者“速度公式”选错了。如果你发现鱼跟手很快但总在鼠标附近“抖动”大概率是跟随太脆了鼠标移动一点点鱼就会剧烈反应。解决方法是降低跟随系数或者加更大的速度衰减。我自己试出一个经验值位置插值系数0.15左右速度衰减0.9~0.95左右整体感觉会非常顺滑。如果你发现鱼“发飘”就是感觉鱼不受控制地滑动多半是速度衰减太弱了。衰减系数0.8~0.85会让鱼更快停下来但太低了又会显得僵硬。我最后用的是0.92算是一个比较均衡的数值。另一个常见的坑是忘记乘dt。如果不乘游戏在120Hz高刷屏上速度翻倍鱼游得飞快如果不乘在低帧率电脑上又慢得难以忍受。用dt统一起来之后不管什么设备体验都一致。这个问题排查起来最隐蔽因为现象看起来是手感问题而不是代码问题。我那次调试到凌晨最后才意识到是这个原因。4.2 碰撞判定总是“过了”或者“错了”怎么办我遇到过好几次这种情况碰撞发生了但没触发“吃掉”或者“碰都没碰到就死了”。这类问题多半出在数据流上——不少情况下你在update里改了player.size和player.radius但是在render阶段使用了旧的radius做判断。仔细思考一下碰撞检测里用的a.radius、b.radius在运行时是否始终是最新的比如你在一个碰撞循环里先判断A和B然后把B从fishList里删掉了然而循环里还在继续使用B就会出现“幽灵鱼”明明消失了还能撞你。解决方法是遍历数组时从最后往前面遍历for (let i fishList.length - 1; i 0; i--)删除当前元素时不影响前面元素的索引位置。我之前直接用for...of加splice结果就会出现跳过一个鱼的问题查了很久才发现是遍历方向错了。再有一个非常隐蔽的问题碰撞时用到的size是浮点数如果判断条件是player.size fish.size理论上没问题但实际游玩时会遇到“看起来一样大却互相吃不了”的情况。这是因为浮点误差两个数几乎相等但严格比较还是有一个大一点点。我的建议是加一个“吃”的阈值if (player.size fish.size * 1.1) { // 必须大10%以上才算大于 // 吃掉 } else { // 死亡或无事发生 }这个1.1让游戏提供了一个“体格相当则相安无事”的缓冲带避免“贴脸判断生死”的荒谬感。同时也能很好地避免因为浮点精度导致误判定。4.3 对象数量增多后游戏变卡怎么优化《大鱼吃小鱼》的AI鱼数量如果设到50条以上再加上复杂的背景滤镜和阴影性能瓶颈就会显现出来。我见过很多人的项目在鱼数量达到80条左右就开始掉帧。优化思路是分层级的第一层是“对象池”。不要每吃一条鱼就new一个对象也不要删除时直接丢弃而是维护一个空闲数组let fishPool []; function getFish() { if (fishPool.length 0) return fishPool.pop(); return { type: ai, ... }; } function recycleFish(fish) { fishPool.push(fish); }这样做的好处是减少垃圾回收压力避免游戏运行时间一长就出现卡顿。尤其当鱼数量经常变动时对象池能显著降低GC停顿。第二层是“绘制裁剪”。屏幕外的AI鱼可以跳过绘制因为玩家根本看不见它们绘制也是白费性能function isVisible(fish) { return fish.x -50 fish.x canvas.width 50 fish.y -50 fish.y canvas.height 50; }这里的-50和canvas.width 50是预留一点外边距避免鱼在屏幕边缘时因为贴图尺寸太大而被误裁剪。第三层是“碰撞优化”。如果鱼数量大了遍历所有鱼检测碰撞会变成O(n²)很耗性能。对这类休闲项目缓存一些区域网格能显著提速。实现方式比较简单把Canvas分成若干网格每个网格存鱼列表碰撞检测时只需要检查同一网格以及相邻网格的鱼。我实测过100条鱼时网格优化后整体帧率提升了30%以上。不过要注意网格尺寸最好是“最大鱼半径”的两倍以上否则大鱼跨网格时会把检测漏掉。这里我用的是40x40网格配合“鱼的半径最大不超过18”的设定不会漏判。4.4 “吃鱼没感觉”的反馈问题很多同学做完这个项目反馈说“玩起来很平淡感觉不到自己变强了”。这其实是反馈设计的问题。一个游戏要让人觉得“爽”光有逻辑不够还得有视听反馈。首先是视觉反馈。吃掉一条鱼时我建议加上一个粒子爆炸特效。不用复杂用少量小圆片向外扩散、变大变小、逐渐消失即可let particles []; function spawnParticles(x, y, color) { for (let i 0; i 8; i) { particles.push({ x, y, vx: (Math.random() - 0.5) * 120, vy: (Math.random() - 0.5) * 120, life: 0.6, color }); } }然后在update(dt)里更新粒子位置和生命周期在render()里绘制。粒子效果不需要多华丽关键是让玩家的每一次“吃掉”都有明确的视觉反馈大脑才会分泌多巴胺。其次是数值反馈。每吃掉一条鱼分数跳动一次最好再有一个“12”这样的浮动文字从鱼的位置冒出来。这个反馈和粒子一样都是让“吃掉”动作变得有实感。最后是音效。用Web Audio API可以很轻量地生成一个“啵”的音效不用加载任何外部音频文件function playEatSound() { let ctx new (window.AudioContext || window.webkitAudioContext)(); let oscillator ctx.createOscillator(); let gainNode ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); oscillator.frequency.value 500 Math.random() * 200; oscillator.type sine; gainNode.gain.setValueAtTime(0.2, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime 0.1); oscillator.start(); oscillator.stop(ctx.currentTime 0.1); }这里每次吃鱼就创建一个新的AudioContext虽然方便但多了会有内存开销。更好的做法是全局只建一个AudioContext每次触发时创建OscillatorNode即可。不过在练手项目里上面的写法够用重点是让大家感受一下音效如何低成本地增强反馈。5. 扩展玩法与项目衍生方向5.1 加入“进化”系统做完基础版《大鱼吃小鱼》之后如果你想把它变成更有深度的作品我建议优先加“进化”或“升级”系统。比如设置几个等级节点当player.size达到30时鱼的贴图换成更高级的种类从带鱼变成小鲨鱼移动速度提升甚至解锁一个“冲刺”技能。这种阶段性成长目标能让玩家的动机从“单纯续命”变成“追求下一形态”续玩时间会大大拉长。实现思路也不复杂在update里判断player.size的阈值达到后切换贴图素材、修改粒子颜色、播放一段升级音效。然后调整AI刷新策略让更大的鱼更频繁地出现维持难度。5.2 接入排行榜与每日挑战当你的游戏逻辑稳定后下一步可以接一个最小的后端服务比如用JSON文件或简单的服务端API记录最高分。排行榜的实现要比想象中简单本地存储一个分数数组排序取前三要做全服排行就接一个云数据库。我给某次项目实践的建议是先从本地排行榜做起理解数据持久化再考虑网络同步。本地排行榜虽然简单但它涉及排序算法、存储格式、成绩展示等很多细节是一个很好的训练项目。5.3 从Demo到可运营游戏还差什么如果你想让这个项目不止于“能玩”而是真正像一个可运营的产品还有几件事要做开始界面标题、操作说明、难度选择、关卡系统比如第1关目标是吃到30分第2关限时90秒、世界地图浅海、深海、海底洞穴外加一套完整的音效与音乐。这些部分加起来的工作量是核心玩法的三到五倍。但正是这些打磨决定了它是“一个练习作品”还是“一个游戏作品”。我个人在实际操作中的体会是《大鱼吃小鱼》这个项目最大的价值不在代码本身而在于它能逼着你从“功能实现”思维切换到“体验设计”思维。你想让玩家在什么时候感到紧张什么时候感到爽快什么时候感到好奇都需要你有意识地设计。这比单纯把代码写对要难得多也更有意思。如果你正在做一个类似的项目卡住了我最后想说一个常被忽略的点手感这种东西不是写出来的是试出来的。你会花很多时间微调参数、观察反应、推翻重来这都很正常。保持耐心调起来。