1. 内容整体设计与思路拆解1.1 这个项目为什么值得做“大鱼吃小鱼”在游戏产品里算是家喻户晓的休闲玩法看起来简单但它其实把游戏开发中最核心的“循环”完整地串起来了玩家输入、帧循环、碰撞检测、状态更新、画面渲染、游戏结束判定。一套走下来你对“一个游戏是怎么跑起来的”会有非常直观的认识而不是停留在背API的层面。我之前带过不少刚入门的开发者发现一个现象很多人能写静态页面、能做组件交互但一提到“游戏开发”就发怵觉得要学物理引擎、要学复杂的渲染管线。其实完全不必像这种“大鱼吃小鱼”的小游戏用非常朴素的数学就能实现可能比很多人想象中简单得多。它不需要额外装任何库浏览器打开直接就能跑拿来练手再好不过。这个项目的核心价值有三块第一让你理解游戏循环动画帧驱动到底是怎么回事第二让你掌握碰撞检测这种高频交互的基础写法第三让你体会数值设计的重要性——为什么游戏好玩很多时候不是画面多华丽而是数值反馈给得准、给得舒服。1.2 玩法规则里的隐藏设计把玩法拆开看规则无非这么几条玩家控制一条鱼在海洋场景中游动。场景中分布着大小不一的鱼比玩家小的可以被吃掉比玩家大的会反过来吃掉玩家。每次吃鱼后玩家体型变大速度相应变化。一旦碰到比自己大的鱼游戏结束。但往细了想这里面的每一个“简单规则”都对应一个设计决策。比如“什么算比我小”直接比较半径看似合理但如果两条鱼半径一模一样互相都判定不了就会出现“贴脸僵持”的尴尬局面。所以我一般会把判定条件做成当前鱼半径 目标鱼半径 * 1.1留出10%的容差游戏体验立刻就顺滑了。再比如“吃鱼后怎么长大”如果直接让半径累加那吃掉一条中等的鱼体型就会瞬间暴涨视觉上非常跳跃。更合理的做法是基于圆形面积相加两个圆合并后新的半径取sqrt(r1² r2²)这样增长是连续的、渐变的既符合直觉又没有爆点。这个过程其实就是很朴素的“合并面积等价”思想理解起来也不难。1.3 技术方案选择为什么用 Canvas 而不是 DOM 或 SVG这是我每次都会跟读者强调的一个点选技术方案不要凭“哪个熟悉”来选要凭“哪个合适”来选。这种实时反馈的休闲游戏场景里同时可能有几十条鱼在移动、碰撞、翻转朝向如果用 DOM 节点来做每个帧都要高频操作几十个节点的样式属性浏览器重排和重绘的压力会非常大。SVG 稍微好一些但元素多起来后性能和内存表现也不理想。Canvas 的优势在于它本质是“一帧一画”每帧直接操作像素缓冲区所有渲染集中在一次绘制里完成对这类场景完全是量身定做。另外Canvas 对碰撞检测也很友好。因为鱼本身用圆形近似我只需要维护一堆“圆心坐标 半径”的数据碰撞判断退化为两个圆的距离计算。如果用 DOM反而还得考虑元素边界、旋转角度、缩放比例这些额外信息平白无故增加复杂度。注意用 Canvas 做游戏思维模式要从“操作元素”切换成“绘制画面”。一个常见的误区是试图去“获取某个画出来的鱼”其实画布上没有“鱼”这个东西鱼只是数据画布每帧重新把数据画出来而已。2. 核心细节解析与实操要点2.1 鱼的大小体系设计先从数值说起因为所有交互都建立在体型对比上。我采用的初始参数是这样的对象半径范围像素速度范围像素/帧60fps说明玩家初始20约2.5~4随体型变大而变慢小鱼8~152~3玩家一开始就能吃中型鱼16~301.5~2.5前期碰到会死大鱼31~501~2和中型鱼一起构成威胁这个表看似随意实际每一行都有意图。速度设定上有个反直觉的点越大的鱼反而应该越慢。这既符合自然直觉大个头的敏捷度低也给玩家留了操作空间——见到大鱼时可以靠鼠标绕开而不是绝对必死。如果大鱼跑得和玩家一样快几个大块头聚在一起时根本没法玩。玩家的速度衰减我也用了公式控制核心是让“变大”这件事有代价而不是无脑叠数值。大致逻辑是速度上限随半径增加线性下降但保底一个最小值保证任何时候玩家都能移动。具体的衰减系数我建议多试不同手感预期差别很大偏休闲就把衰减放慢偏挑战就加大惩罚。2.2 移动控制鼠标跟随与朝向计算玩家控制我用的是鼠标跟随而不是键盘方向键。为什么理由很简单键盘的另一个缺点是操作视角和动作意图之间隔了一层“方向”不够自然。鼠标跟随模式下玩家手指的移动和鱼的运动完全同步反馈链路最短适合休闲游戏“上手即玩”的调性。实现上我在鼠标事件里记录一个目标坐标然后在每帧更新时让鱼朝目标坐标移动。鱼的位置不等于鼠标位置而是一步步“追过去”这样动效有惯性看起来像鱼在游动而不是瞬间瞬移。鱼游动时应该脸朝移动方向这里用atan2(dy, dx)算出朝向角绘制时统一按这个角度旋转。没做朝向处理的鱼会“横着游”非常出戏。2.3 碰撞检测原理与常见坑碰撞检测的核心就是计算两个圆的圆心距离然后和半径之和进行比较dist sqrt((x1 - x2)^2 (y1 - y2)^2) 如果 dist r1 r2说明发生了碰撞这个判断本身很简单但实际项目里有一堆细节坑。第一个坑是平方根计算的性能。每帧要对几十条鱼做两两碰撞检测每次都算Math.sqrt其实没必要。效率更高的做法是先比较距离的平方和半径之和的平方只在最终判定时才解算根号。不过对于这种几十条鱼的小场景性能差异几乎看不出来但养成好习惯不是坏事。第二个坑是相邻帧的抖动。两条鱼如果同时互相检测“我能不能吃你”会出现一帧吃掉对方、下一帧又被对方吃掉结果两条鱼同时消失。我的策略是逐一判断先取当前帧的玩家遍历所有其他鱼只由玩家单方面发起“吃”的动作其他鱼之间不互相吃。这样逻辑简单、无歧义。第三个坑是“吃到”和“碰撞”的区分。碰撞是双方半径之和的几何接触但能否吃掉是大小比的判定。中间要有个缓冲带接触但不满足吃鱼条件时不给任何惩罚让玩家推开——这样小体型的玩家贴到比自己大一点点鱼边上不至于莫名其妙暴毙。2.4 鱼群的生成与回收机制场景中的鱼不能一次性全部生成那样每局体验差别太大。我采用了动态生成策略每秒钟补充1到2条鱼当场上总数量低于一个阈值比如30条时持续补充。生成位置要在可视范围内随机但必须避开玩家周围一定距离否则玩家刚开局就被大鱼骑脸挫败感拉满。被吃掉的鱼直接从数组中删掉。但如果一局游戏玩得久反复创建销毁对象会产生大量垃圾回收压力。更优雅的方案是对象池初始化时预创建一个对象池被吃掉的鱼标记为“死亡”重置一下数据后放回池里下次生成时直接复用。这个技巧在这个小项目里属于“优化溢出”但值得了解因为任何帧循环游戏减少GC都是提高流畅度的重要思路。3. 实操过程与核心环节实现3.1 初始化页面与 Canvas 适配页面骨架我用了最朴素的写法一个铺满全屏的 Canvas监听鼠标移动。需要注意高分屏适配的问题否则画出来的画面在 Retina 屏上会模糊。适配方案是读取设备的devicePixelRatio把 Canvas 的内部尺寸乘以这个比例再用 CSS 控制显示尺寸。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title大鱼吃小鱼/title style body { margin: 0; overflow: hidden; background: #04142a; } canvas { display: block; background: linear-gradient(180deg, #0a2f5a 0%, #061e3a 100%); } /style /head body canvas idgameCanvas/canvas script srcgame.js/script /body /html在game.js里先做尺寸适配和鼠标状态的初始化。鼠标坐标要存成“画布坐标系”而不是“页面坐标系”这个转换很容易漏。如果用clientX - canvas.getBoundingClientRect().left拿到的是CSS像素坐标Canvas内部实际尺寸可能已经乘以了DPR所以我的做法是初始化时记录一个scale canvas.width / canvas.clientWidth用它把CSS像素坐标还原成画布坐标。3.2 鱼的类设计与渲染鱼用一个极简的类来表示字段只有位置、半径、速度方向、颜色、存活状态。不需要额外继承不需要多态够用就行。class Fish { constructor(x, y, r, vx, vy, color) { this.x x; this.y y; this.r r; this.vx vx; this.vy vy; this.color color; this.isPlayer false; // 标记是否为玩家本体 } }绘制逻辑是重点鱼要画得“像个鱼”而不是一个圆。我的做法是沿着朝向角度旋转坐标系然后画出身体椭圆、三角尾巴、眼睛三个部分。身体是中心对称的椭圆半径决定长轴和短轴。尾巴画成身体尾部的一个三角形颜色用比身体深一档的色值。眼睛是一个小黑点放在靠近头部方向的位置。鱼嘴的方向和移动方向一致所以绘制时在身体朝向的前端加一个小半圆缺口视觉上就形成“嘴”的错觉。function drawFish(ctx, fish, angle) { ctx.save(); ctx.translate(fish.x, fish.y); ctx.rotate(angle); // 身体 ctx.beginPath(); ctx.ellipse(0, 0, fish.r * 1.2, fish.r * 0.8, 0, 0, Math.PI * 2); ctx.fillStyle fish.color; ctx.fill(); // 尾巴 ctx.beginPath(); ctx.moveTo(-fish.r * 0.8, -fish.r * 0.4); ctx.lineTo(-fish.r * 1.6, 0); ctx.lineTo(-fish.r * 0.8, fish.r * 0.4); ctx.closePath(); ctx.fillStyle darkenColor(fish.color); ctx.fill(); // 眼睛 ctx.beginPath(); ctx.arc(fish.r * 0.5, -fish.r * 0.3, fish.r * 0.16, 0, Math.PI * 2); ctx.fillStyle #ffffff; ctx.fill(); ctx.beginPath(); ctx.arc(fish.r * 0.6, -fish.r * 0.3, fish.r * 0.08, 0, Math.PI * 2); ctx.fillStyle #000000; ctx.fill(); ctx.restore(); }颜色深一档的实现可以用ctx.fillStyle配HSL值的调整也可以简单地把RGB各分量乘以一个系数在小项目里没必要引库。3.3 主循环与更新逻辑主循环用requestAnimationFrame驱动这是标准做法比setInterval更平滑且天然跟浏览器刷新率同步。我把更新和绘制分开物理计算全部在update里完成draw只负责根据当前状态绘制画面。let fishes []; let player new Fish(innerWidth / 2, innerHeight / 2, 20, 0, 0, #ffd700); player.isPlayer true; let score 0; let gameOver false; let state playing; // playing | over function spawnFish() { if (fishes.length 30) { const r 8 Math.random() * 42; const side Math.floor(Math.random() * 4); let x, y; // 从屏幕四边生成避免出现在玩家正前方 switch (side) { case 0: x -r; y Math.random() * innerHeight; break; case 1: x innerWidth r; y Math.random() * innerHeight; break; case 2: x Math.random() * innerWidth; y -r; break; case 3: x Math.random() * innerWidth; y innerHeight r; break; } // 直接生成在外侧让鱼游入视野 ... } }玩家移动的细节是“平滑跟随”不是直接跳到鼠标位置。我用一个lerp思想每帧按比例向鼠标目标靠近比例取一个与帧率无关的经验值。如果直接用固定像素偏移刷新率不同的设备手感差别巨大。为了方便我这里直接用速度方案计算当前鱼朝向目标的单位向量乘以最大速度再乘一个阻尼系数。这样即使鼠标不动鱼也有最小的游动惯性看起来更自然。吃鱼判定放在更新循环的最后遍历所有其他鱼命中碰撞且满足大小条件时把对方标记为死亡同时更新玩家半径、分数。成长公式在代码里非常直观const newR Math.sqrt(player.r * player.r target.r * target.r); player.r Math.min(newR, 120); // 设置一个最大半径上限上限很重要否则数值溢出后鱼的尺寸会大到屏幕都装不下而到了那个阶段游戏其实已经没有挑战性了。3.4 计分与游戏结束状态计分我用了最简单直接的方案按吃掉的鱼半径累计分数。半径越大加分越多这样鼓励玩家挑战中等偏大的鱼而不是只挑最小最容易的吃。我用的公式是score Math.floor(target.r * target.r / 10)本质上还是按面积折算。游戏结束状态要做成独立的state分支。一旦玩家碰到比自己大的鱼立即把state置为over主循环里的碰撞检测逻辑不再执行。绘制时遮罩一层半透明的深色背景居中显示“游戏结束”和最终得分再画一个“重新开始”的提示区域。点击后重置所有数据重新初始化玩家位置和鱼群。3.5 边界处理与细节打磨鱼碰到边界时我采用的是“环绕”策略游出左侧边缘就从右侧进入游出顶部就从底部继续。这样做的好处是鱼群永远存在不会堆在边界出不去玩家不需要掉头找鱼。但要注意环绕的瞬间不能把玩家坐标清掉否则分数和状态容易乱。我当时实现时踩过一个小坑环绕只对普通鱼生效但玩家也继承了同一个类的字段导致鼠标跟随目标在跨边界后出现跳变。后来加了isPlayer标记玩家的移动完全靠鼠标坐标驱动不做边界环绕只在绘制时判断一下位置是否超出画布超出就把鱼“夹”回边界内另一种柔和处理。4. 常见问题与排查技巧实录4.1 一帧吃掉多只鱼导致体型暴涨现象玩家游过鱼群密集区域时体型瞬间变大视觉上很突兀甚至分数跳变。原因遍历碰撞时连续判断了多只鱼且所有吃鱼动作都加成到同一帧。解决每次玩家吃鱼后限制一轮更新中的连吃次数为1~2次或者把吃鱼动作放到碰撞收集结束后的统一结算里。还有一种方案是加入一个短暂的“消化”间隔比如50毫秒内最多吃一条鱼。从游戏性角度这个缓冲让玩家能清楚看到自己到底吃的是哪条而不是“糊里糊涂变大了”体验反而更好。4.2 两条体型相近的鱼互相卡住现象玩家在边界或角落遇到一条略大的鱼无法前进也无法后退一直在贴脸抖动。原因碰撞检测和朝向计算相互冲突——检测到了碰撞但大小判定不满足被吃条件而鼠标朝向的另一侧又被其他障碍堵住。解决碰撞后加入一个“推开”的力。当不满足被吃条件时把玩家和对方沿圆心连线方向各自推开一段距离。加上这段位移后玩家可以绕过威胁而不是被钉在原地。我实际在代码里加了这样一个块if (dist player.r fish.r) { if (player.r fish.r * 1.1) { // 吃掉鱼 } else if (fish.r player.r * 1.1) { // 游戏结束 } else { // 推开 const angle Math.atan2(fish.y - player.y, fish.x - player.x); player.x - Math.cos(angle) * 3; player.y - Math.sin(angle) * 3; fish.x Math.cos(angle) * 3; fish.y Math.sin(angle) * 3; } }这个细节非常影响手感加了之后同体型僵持基本消失了。4.3 帧率下降导致鱼群“漂移”现象在性能较弱的设备上鱼的运动速度看起来不稳定一会儿快一会儿慢。原因主循环基于requestAnimationFrame它按浏览器刷新率回调。在60Hz屏幕上和120Hz屏幕上每秒执行帧数不同而运动增量是固定的像素/帧所以高刷屏上鱼明显更快。解决引入deltaTime把单位从“像素/帧”改成“像素/秒”。每帧获取当前时间戳和上一帧的差值位移量乘以dt系数。这样无论屏幕刷新率如何鱼的绝对速度都一致。这在移动端尤其重要因为手机和高刷显示器的刷新率差异非常大不做时间修正游戏在不同设备上体验完全不同。4.4 鱼的数量无限堆积内存暴涨现象玩到后期如果玩家一直不吃小鱼场上鱼的数量不断累积导致内存和绘制开销持续上升。原因生成逻辑只看“场上鱼数量是否少于阈值”但阈值判断是在生成触发点如果触发生成后鱼因为玩家不主动去吃、且相互之间不碰撞它们就一直存在。解决除了上限数量我再增加一个“生存时间”机制每条鱼生成时记录时间戳超过20秒后如果还存活就逐步淡化并移除。这样即使玩家挂机场景也会自动清理旧鱼、引入新鱼保持动态平衡。这个机制我后来发现其实也是一种难度调节器旧鱼消失、新鱼重新刷新玩家面临的鱼群分布不会固化刷新的随机性保证了每一局都有新鲜感。4.5 首次点击页面后鱼不响应现象游戏加载出来了画布也正常显示但鼠标移动没有效果鱼在原处僵住。原因多半是事件监听没有正确挂载或者监听的元素不对。我自己真的犯过挂在document上、又使用了canvas.addEventListener来取坐标的错两者坐标基准不同。解决把mousemove事件统一挂在window上坐标统一走event.clientX/clientY再用画布边界做偏移修正。触摸端可以用touchmove事件做类似处理坐标用touches[0].pageX/pageY。4.6 分数显示位置和大小字号要适配不同屏幕这个不算BUG属于细节打磨。分数如果直接用CSS像素写死在手机屏幕上会显得过大。简单方案是用canvas.width / 20之类的比例动态计算字体大小或者把分数绘制在一个固定比例的位置和字号上再乘以DPR缩放。我当时图省事直接固定字号后面发现在不同分辨率下效果差别很大还是花了几分钟做了动态适配。5. 进阶玩法与扩展方向5.1 给鱼加简单AI当你把基础版做完后最值得做的扩展是给普通鱼加简单的“逃跑和追击”AI。实现思路也不复杂每条鱼每帧检查附近是否出现比自己大或比自己小的鱼各自设定一个“威胁半径”朝相反方向或靠近方向调整运动向量。这个改动立刻就把“死水一潭”的场景盘活了鱼群会自主聚散游戏可玩性提升一大截。AI加完之后数值平衡会变得微妙起来如果AI鱼太聪明玩家永远追不上小鱼如果太迟钝一开局鱼群就全自动送死。我的建议是先设定“玩家无法直接观察到的AI反应延迟”给AI一个200到400毫秒的决策间隔这样模拟出鱼群的“应激迟钝感”不至于让游戏变得太硬核。5.2 道具与事件机制经典的加强道具无非三种加速、护盾、缩小药剂。加速让玩家短时间内追上大鱼护盾让玩家撞到大鱼时不死缩小药剂把体型往回调用来应对体型过大后速度过慢的尴尬。道具在场景中随机刷新做成发光的圆形能量球玩家吃到之后进入一段持续时间的Buff状态绘制时给玩家外圈加一圈辉光直观可见。这个扩展的工程量和基础版差不多但因为引入了“临时状态”的概念你会学到状态管理和计时器的配合比一直只做大小判定有趣得多。5.3 存档与排行榜用localStorage保存最高分、游戏场次、达到的最大体型对新手来说是一个完整的“持久化数据”练手案例。如果想进一步可以把最高分按日期排序画一个简单的历史分数曲线这就是一个迷你数据可视化了。排行榜的引入有一个好处游戏的重复可玩性立刻上升玩家为了刷分数会对数值反馈更加敏感。5.4 从 2D 到 3D 的想象空间做完2D版本如果你有兴趣可以试试用 Three.js 把场景换成 3D 水下视角。虽然改动很大但你之前积累的“碰撞检测思想”“移动控制思路”仍然适用只是把圆和半径换成了球和三维向量。这种“小步跨越”的过渡方式比直接啃一个大型3D游戏项目要舒服得多。6. 实操中的经验与心得做这个小游戏的过程里我个人收获最大的不是某个API或某个公式而是“如何把一个模糊的玩法描述拆成可执行的代码模块”。很多人一拿到“大鱼吃小鱼”这种题目脑子里立刻蹦出的是画面、动画、音效这些表象反而忽略了底层的数据结构和逻辑分层。但代码跑起来之后你会发现真正支撑画面的永远是那几个简单的数学关系和清晰的状态流转。在实际操作中我做事的顺序是这样的先把规则用最朴素的文字写清楚列成表格然后把数据模型确定下来——玩家是什么、鱼是什么、它们在每一帧如何更新最后才写绘制代码让数据可视化。这个顺序反过来做往往会把代码写成一团乱麻因为画面驱动逻辑很容易让状态变得不可控。还有一点我想多强调一次数值调优需要反复试没有一次能调到位。我每一次改速度和成长系数都要开一局游戏实际跑几十秒观察手感是否“顺滑”。这个“顺滑”的感觉很难量化但你玩过之后一定能判断。这也是做游戏和做普通程序的本质区别之一游戏的体验好坏不只看功能正不正确更要看感受对不对。如果你是自己练手建议从一个小改动开始试试把成长公式的系数改一下从sqrt(r1² r2²)改成sqrt(r1² r2²) * 0.8去吃几条鱼看体型变化。你会立刻明白哪怕公式只差一个系数整个游戏的节奏都会天差地别。这种直观体验比我在这里写一千字文字描述都更有用。