
如果你小时候在网页上玩过“大鱼吃小鱼”应该还记得那种既紧张又爽快的体验拼命寻找比自己小的鱼又时不时瞥见一条大一圈的家伙缓缓游来手心冒汗。这个经典玩法放到今天依然不过时它天然具备快速反馈和成长快感很适合拿来当作游戏开发的练手项目。我最近从零做了一版网页版的“大鱼吃小鱼”过程不算长但遇到不少有价值的问题。这篇文章就围绕这个自研Demo来写把玩法建模、AI设计、碰撞处理、性能优化和踩坑经验完整记录下来适合已经掌握一定基础编程、正想找一个完整小项目沉淀技术的朋友参考。1. 为什么说这个玩法天生适合当全链路练手项目先说结论想在短时间内体验“从零做一个游戏”的全过程“大鱼吃小鱼”几乎是最划算的选择。它不像卡牌或RPG那样需要大量美术资源、剧情文本和系统策划画面元素简单到一条鱼、一个圆就能表达但背后涉及的游戏开发核心模块却一个都不少。很多人一开始想做宏大项目结果人还没开始走路就想学飞进度卡在人物动画或地图加载上最后不了了之。而“大鱼吃小鱼”的好处在于你把规则想清楚之后只要写几百行代码就能看到一个动态的、可操作的游戏世界在浏览器里跑起来。这种即时反馈对维持开发动力极其重要。具体到技术点这个Demo覆盖了以下关键环节数据建模鱼群对象、玩家对象、食物对象、淘汰规则每个实体有位置、半径、速度等属性。行为设计鱼群AI的三种状态游荡、追逐、逃跑以及状态切换的条件。碰撞与判定圆形碰撞检测以及“谁吃谁”的吞并逻辑。渲染循环基于浏览器的逐帧渲染处理时间步进与帧率波动。性能优化当鱼数量上升到一百多条时如何避免碰撞检测变成性能瓶颈。体验打磨速度曲线、缩放动画、视觉反馈这些决定游戏好不好玩的关键细节。一套流程走完你对“游戏手感”这个词的理解会从抽象变成具体——手感不是玄学而是数值和反馈的叠加。更重要的是它的扩展空间巨大。基于同一套基础逻辑你可以加入道具、技能、排行榜甚至改成双人对战。练手项目最怕做成一潭死水而这个玩法天然适合后续加料做完不会觉得无路可走。2. 核心规则与数值设计先定规则再写代码项目动手之前我花了不少时间把规则彻底理清。很多人拿到这个题目就急着写代码写到一半才发现规则没定清楚导致返工。比如两只一样大的鱼碰撞时怎么办玩家变大之后速度降多少AI鱼之间会不会互相吞噬如果你自己都说不清楚代码里就会充满随意临时补丁。2.1 一句话规则玩家控制一条鱼碰到比自己小的鱼就将其吞噬并成长碰到比自己大的鱼则被吃掉并扣除一条命。AI鱼群拥有自己的游动、觅食和逃生逻辑玩家需要在有限的生命次数内尽可能长得更大、活得更久。因为规则简单上手门槛极低这保证了任何人打开页面十秒内就能理解目标。但“简单”不代表没有设计空间关键在于数值参数的拿捏。2.2 六个核心参数我先列一张自己项目里用到的初始参数表实际开发时可以参考但不建议照抄因为手感这种东西最终要结合美术表现和玩家体感微调。参数初值作用玩家初始半径12决定起点尺寸和初始吞噬能力鱼群半径范围6 ~ 45控制场景中大小鱼的分布广度吞噬半径比例1.1只有大鱼半径超过小鱼1.1倍时才可吞噬避免相等或临界时判定混乱鱼群最高数量120平衡画面丰富度与性能开销AI视野范围300决定AI鱼感知猎物的距离速度衰减系数0.85鱼越大速度越慢越大的鱼越笨重这里我想重点解释“吞噬半径比例”。如果规则只是“大于对方就可以吃”那两只尺寸几乎相同的鱼碰撞时就会出现一种很尴尬的反复横跳一瞬间判定我大下一瞬间又判定对方大。所以项目里统一用“半径比对方大10%以上”作为吞噬前提。这10%的冗余看似不起眼实际能消除大量边缘判定冲突。2.3 成长公式的数学细节成长公式是吞噬玩法的灵魂这里我踩过一个经典的数学坑两条鱼相遇后如果直接大鱼半径 大鱼半径 小鱼半径玩家的鱼会在吃了几条后迅速膨胀成遮住半个屏幕的怪物数值彻底崩坏。正确的思路是让面积叠加而不是半径叠加。鱼本身近似圆形“体重”可以理解成面积。吃掉猎物后大鱼得到的是猎物的面积而非半径。换算成半径时要用开方// 假设 player 是被吃者之外的胜者prey 是被吞噬的猎物 player.radius Math.sqrt(player.radius * player.radius prey.radius * prey.radius);这个公式的效果是前期每吃一条鱼体积增长非常明显能给玩家爽感但当你已经很大时吃一条小鱼带来的半径变化微乎其微逼迫玩家去挑战更大的目标。正是这种“越吃越大、大得越慢”的曲线让成长反馈始终存在但不至于失去控制。2.4 速度与体型的平衡为了让大鱼玩家有“笨重感”速度需要随半径增长而下降。我用了一个指数式衰减player.speed baseSpeed * Math.pow(0.85, (player.radius - startRadius) / 5);每次半径比起点多出5速度整体乘以0.85。这个设计让游戏前中期操作流畅后期则更像驾驶一辆越来越难停下来的车玩家需要提前规划游动路线而不是满场乱窜。3. 技术选型一套轻量但完整的网页游戏方案确定玩法后我面临技术选型。任何项目都有很多实现路径区别只在于你投入多少资源、想要什么学习效果。我最终选择的是Canvas 2D JavaScript不引入现成的游戏引擎自己写主循环和渲染逻辑。理由如下这个游戏的规模很小没有地图编辑器、骨骼动画、多人同步等需求直接上引擎等于杀鸡用牛刀。手写主循环能让我把所有底层逻辑摸清楚requestAnimationFrame怎么控制帧率、时间步进怎么处理、渲染和逻辑怎么分开更新。调试和部署成本低打开一个HTML文件就能完成整个开发周期。如果你只是想快速出成果用现成引擎也可以。但从学习角度出发纯手写一套能让你理解引擎背后真正做了什么之后再切换到任何工具都会事半功倍。3.1 项目文件结构我没有把所有逻辑塞进一个文件而是按职责做了拆分这样维护起来舒服很多project/ ├── index.html // 页面入口创建Canvas画布 ├── css/ │ └── style.css // 页面布局与基础样式 └── js/ ├── config.js // 所有数值参数集中管理 ├── Fish.js // 鱼对象的数据结构与基本运动 ├── AI.js // AI状态切换与视野搜索 ├── Render.js // 绘制逻辑背景、鱼、UI ├── Game.js // 主循环、碰撞、吞噬、状态管理 └── main.js // 初始化入口绑定输入事件config.js单独拆出来是个好习惯。前期调参非常频繁如果参数散落在各个类里改一次手感要翻好几个文件。集中放在一处然后用表格清晰地列出来改起来轻松得多。3.2 渲染循环与时间步游戏循环是整个项目的心脏我使用requestAnimationFrame驱动let lastTime 0; function gameLoop(time) { const dt Math.min((time - lastTime) / 1000, 0.05); lastTime time; updateWorld(dt); // 逻辑更新AI、碰撞、成长 renderWorld(); // 绘制鱼、背景、UI requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里有个容易忽视的细节dt做了Math.min限定。因为浏览器在切走标签页再切回来时time时间戳可能突然产生一个很大间隔如果不限幅鱼会像瞬移一样跳一下。限幅到最大0.05秒即使你切后台很久回来时也只是所有鱼缓慢前进一步不会造成视觉突跳。4. 鱼群AI设计让每条鱼看起来像活物在“大鱼吃小鱼”里AI鱼群不是背景板它们的行为直接决定世界是否真实。如果所有AI鱼都只是原地转圈游戏会变成纯粹的数值堆砌索然无味。我的目标是让每条鱼都具备三种行为游荡、追逐、逃跑并根据周围局势自动切换。4.1 鱼对象的数据结构实现前先定义Fish类包含AI所需的全部字段class Fish { constructor() { this.x 0; this.y 0; this.vx 0; this.vy 0; this.radius 8 Math.random() * 37; this.speed 60; this.mode roam; // roam / chase / flee this.targetX 0; this.targetY 0; this.targetChangeTimer 0; this.prey null; this.danger null; } }字段“松鼠大纲”式的定义都是必要项mode记录当前行为状态prey和danger用于暂存视野中找到的目标targetX/Y和targetChangeTimer则驱动游荡行为。4.2 三种AI行为的行为逻辑游荡模式鱼每隔几秒随机选一个目标点然后以当前速度游过去。到达后就原地短暂停顿或直接换个新目标。这种随机游走模仿了水中的漂浮感避免了所有鱼固定往同一方向移动的机械感。追逐模式当AI鱼发现视野范围内有比自己小10%以上的鱼时切换为追逐。这里我加了一个策略不是盲目追最近的猎物而是优先追半径差距最小但能吃掉的鱼。因为追太大的猎物有一定风险追太小又可能浪费体力稳妥节奏会更有生机。逃跑模式当AI鱼发现视野内有一条比自己大10%以上的鱼时立刻切换为逃跑方向是危险来源的反向。逃跑优先级最高只要感知到危险就放弃一切觅食计划这一点很有必要否则鱼群会经常被大鱼“一锅端”。4.3 AI切换的实现AI的切换逻辑用一段核心伪代码可以说明function updateAI(fish, fishes) { let danger null; let prey null; for (const other of fishes) { if (other fish) continue; const distance Math.hypot(other.x - fish.x, other.y - fish.y); if (other.radius fish.radius * 1.1 distance seeingRange) { if (!danger || other.radius danger.radius) { danger other; } } else if (fish.radius other.radius * 1.1 distance seeingRange) { if (!prey || other.radius prey.radius) { if (other.radius fish.radius * 1.8) { prey other; } } } } if (danger) { fish.mode flee; fish.danger danger; } else if (prey) { fish.mode chase; fish.prey prey; } else { fish.mode roam; } }这里对猎物半径增加了一个“不追击比自己小太多”的限制other.radius fish.radius * 1.8。如果不加这个限制最大的AI鱼永远都在追最小的AI鱼中等鱼又追更小的鱼场景会变成全员向前狂奔的闹剧。有了限制后鱼群会自动形成一种“各自有合适目标”的相对稳定生态。4.4 让视觉更自然的鱼群排斥力AI逻辑跑通后我又发现一个问题鱼群偶尔会重叠甚至几条鱼完全穿模叠在一起观感极其出戏。原因是AI只考虑了捕食关系没有考虑鱼身体之间的物理排斥。解决办法是在移动阶段加一个简单的排斥力遍历所有鱼计算碰撞对距离小于两者半径和时就施加一个推开的力。不需要做成完整的物理碰撞只是把重叠的鱼轻柔推开就能让世界看起来合理很多。5. 玩家操控、碰撞判定与吞噬成长AI鱼群与玩家的交互是游戏的核心体验。这一章把玩家控制流程和吞并判定讲清楚。5.1 操控方式与手感玩家操控我做了三套输入方案键盘方向键或WASD控制鱼朝指定方向游动。鼠标鱼朝鼠标位置游动按住左键冲刺。触屏手指按住屏幕拖动鱼朝手指位置游动。电脑端最常见的控制方式是鼠标跟随因为它最自然。鱼的方向不是直接跳跃到鼠标方向而是做了转向插值——每次更新时把当前角度向目标角度逼近一个比例。这会让鱼的转向带有惯性游起来有“滑行感”而不是僵硬地瞬间转身。// angle 是当前朝向targetAngle 是目标方向 function lerpAngle(angle, targetAngle, t) { let diff targetAngle - angle; while (diff Math.PI) diff - Math.PI * 2; while (diff -Math.PI) diff Math.PI * 2; return angle diff * t; } player.angle lerpAngle(player.angle, Math.atan2(targetY - player.y, targetX - player.x), 0.08);5.2 圆形碰撞与吞噬条件所有鱼都抽象成圆所以碰撞判定简化成了两圆是否相交距离是否小于半径和。但“相交”不等于“吞噬”只有满足大小关系的相交才会触发吞噬。function tryEat(fishA, fishB) { const dx fishA.x - fishB.x; const dy fishA.y - fishB.y; const distance Math.hypot(dx, dy); const minRadius Math.max(fishA.radius, fishB.radius) * 1.1; const maxRadius Math.max(fishA.radius, fishB.radius); const minDistance fishA.radius fishB.radius; if (distance minDistance) { if (fishA.radius fishB.radius * 1.1) { // fishA 吞噬 fishB fishA.radius Math.sqrt(fishA.radius * fishA.radius fishB.radius * fishB.radius); return fishB; // 返回被吃对象 } else if (fishB.radius fishA.radius * 1.1) { // fishB 吞噬 fishA fishB.radius Math.sqrt(fishB.radius * fishB.radius fishA.radius * fishA.radius); return fishA; } } return null; }5.3 吞噬后的重生与保护被吞噬的鱼不会立即消失就结束而是会在一段时间后重新生成一条新AI鱼保证场景中始终维持接近上限的鱼群数量。但生成新鱼时要注意不要直接刷在玩家脸上否则会制造大量莫名其妙的口粮降低挑战性。我的做法是选取离玩家一定距离以外的随机位置。这里还有一个新手容易忽视的点吞噬瞬间的冷却与无敌帧。两条鱼相交触发吞噬后如果被吃对象还残留在世界里逻辑上已经被判定为死亡那么它与大鱼之间不应继续参与碰撞。所以每次吞噬后立刻将被吃对象标记为“已死亡”并从碰撞遍历中移除同时给玩家极短暂的无敌时间大约0.5秒避免刚吞完一条鱼又同时撞上另一条大鱼的“连续死亡冤案”。5.4 死亡重生与生命周期管理玩家也被吃掉后进入死亡动画和复活流程。我设置三条命死亡时画面短暂闪红然后从初始位置以无敌状态重生。重生时提供约1.5秒无敌时间让玩家有反应空间不至于复活瞬间又被秒杀。整个生命周期用状态机来管理alive正常游玩dying播放死亡特效暂停操控respawning无敌阶段玩家可操作但不受伤害gameover生命值为0游戏结束状态机的好处是代码路径清晰所有可能的游戏状态一目了然不用担心“复活后还能不能移动”这种边界问题。6. 视觉细节与体验打磨从能玩到耐玩“能玩”和“好玩”之间往往隔着大量细节。算法和规则只是基础真正让玩家感受到品质的是那些不经意的视觉反馈和动效。6.1 场景表现与氛围我没有用真实鱼类图片而是用不同颜色的圆形加一条小尾巴表示鱼再用半透明背景营造水下感。这个做法有两个好处第一不用处理图片素材完全用绘图API完成第二圆形的精简让鱼群的移动和目标状态一目了然。背景绘制了三层半透明的水纹线每隔几十秒随机改变缓慢漂移方向让画面不至于静止得像一张截图。这层效果成本极低但对“水感”的提升非常大。6.2 鱼的视觉区分鱼的颜色我按半径范围做了分层小号鱼以浅蓝、浅绿色调为主中型鱼使用稍深的橙色和紫色大型鱼则使用深红或深蓝色。这种分层让玩家能凭颜色快速判断鱼的大致尺寸减少认知负担。玩家鱼使用了白色描边和尾迹光晕在任何背景色上都非常显眼。AI鱼被吞噬时会播放一个缩小消散的动画——就是几帧内逐渐降低透明度和缩放比例能有效传递“我吃到东西了”的预期反馈。6.3 吞噬反馈和屏幕震动吞噬瞬间的反馈是最重要的手感来源。我在项目里做了两件事吞噬时全局暂停约0.05秒的时间步然后再恢复运行制造类似“子弹时间”的瞬间卡顿感。画面有极轻微缩放震颤玩家鱼在吞噬后的两帧内放大到1.1倍再回弹模拟“吞了一口东西”的膨胀感。这种细微的节奏控制比单纯加粒子特效还要有效因为它作用于时间维度给玩家大脑一个明确的“得分”信号。6.4 HUD与相机跟随当玩家鱼慢慢变大视野范围会显得不足于是我给游戏加了一个简易相机以玩家鱼为锚点将Canvas平移居中。同时相机有一个缩放逻辑当玩家半径超过30后相机逐渐拉远让玩家能看清更大范围的局势不至于一条鱼占据整块屏幕。HUD部分显示当前半径或长度、生命条、得分。得分与半径挂钩每条鱼被吞噬时按猎物体积给予分值最终排名以累计得分为准。UI尽量做得轻避免遮挡太多视野。7. 性能优化与踩坑实录说点实在的。我跑通基础玩法后鱼群数量一调高帧率立刻掉了。这里有性能优化也有几个比较隐蔽的Bug值得记录。7.1 对象池别频繁创建新对象最初每次生成新鱼都是简单new Fish()但在鱼群死亡重生频繁时短时间大量创建对象会造成垃圾回收抖动具体表现为游戏每隔几秒就卡一下。解决办法是做一个简单的对象池class FishPool { constructor(initialSize) { this.pool []; this.active []; for (let i 0; i initialSize; i) { this.pool.push(new Fish()); } } spawn(x, y, radius) { let fish this.pool.pop(); if (!fish) fish new Fish(); fish.x x; fish.y y; fish.radius radius; fish.alive true; this.active.push(fish); return fish; } despawn(fish) { fish.alive false; const index this.active.indexOf(fish); if (index -1) this.active.splice(index, 1); this.pool.push(fish); } }对象池准备好后所有鱼的新增与移除都走池子的spawn和despawn方法不再直接new或删除数组元素。运行一段时间后游戏全程稳定流畅也没有GC卡顿。7.2 碰撞检测剪枝空间网格当鱼数量在100条以下时对所有鱼两两比较碰撞复杂度是O(N²)性能可以接受。但当我调高到300条鱼时每帧要做近4万5千次距离计算明显吃力。我把碰撞检测改进成了简单的空间网格法。将整个画布划分成若干个格子大小等于最大鱼直径每条鱼只与同一格子和相邻格子中的鱼进行碰撞判定。这样距离较远的鱼对完全不会进入比较计算量大幅下降。function getGridKey(fish, gridSize) { const gx Math.floor(fish.x / gridSize); const gy Math.floor(fish.y / gridSize); return ${gx},${gy}; }在每次逻辑更新时先遍历鱼群生成MapgridKey, Fish[]再对每个格子内的鱼进行两两碰撞检测。实测300条鱼从每帧卡顿降到全程60帧稳定效果非常明显。如果你的鱼群数量在200条以下其实不做这个优化也能流畅运行但从一开始就写空间网格后续加人数或加AI扩展会从容很多。7.3 踩过的三个坑坑一鱼在目标点附近疯狂抖动表现是鱼到达目标点时不是停下而是在一个很小范围内反复横跳。原因是目标点判定距离设得太小鱼每次游到目标点后下一帧又重新生成了一个新目标点新目标点可能就在原地附近转向插值还没完成就再次转向于是造成抖动。解决办法到达目标点小半径范围后不立即换目标而是让目标点保留并让鱼暂停游动0.5~1秒再随机生成新目标。小小的停顿反而让鱼群看起来更自然像是在原地观察水况。坑二吞噬后反复判定这个坑和前面说的吞噬半径比例有关。如果只判断“我半径大于你”而不加1.1倍冗余当一条半径和另一条刚差0.1倍时两帧内可能判定方向颠倒画面出现“明明吃了又吐出来”的现象。把所有吞噬判定统一改成“必须是1.1倍以上”后此类问题彻底消失。坑三dt没有限幅导致的瞬移这个问题在手机端尤其明显。手机切到锁屏或接电话再回到游戏时requestAnimationFrame会触发一个很大的时间间隔如果没有对dt做上限处理鱼会瞬移到远处。给dt加上0.05秒上限后卡顿场景只会迷你加速不会瞬移。7.4 实测中的调参心得做完所有功能后我调整了几乎一周的数值才找到满意的节奏。这里有几个经验值供你参考玩家初始半径在12~14之间体验最好太小会觉得起点太差太大又会失去成长空间。AI鱼最大半径不要超过玩家初始半径的4倍否则地图里会出现几个超级大块头玩家根本无处可逃。鱼群的最高数量按设备和场景大小来定手机端150条封顶桌面端300条仍然流畅。所有尺寸参数调完后都必须实际试玩至少15分钟再下结论不是跑通Demo就能保证手感的。7.5 从“能玩”到“耐玩”的进阶方向如果之后想把这个Demo继续扩展以下几个方向相对自然且收益高道具系统增加加速、护盾、分身道具让策略维度更丰富。双人对战本地双人模式或联网对战一次赛谁能先吃到指定数量的鱼。排行榜把得分上传到后端形成每日/每周挑战。AI难度梯度随游戏进程增大AI鱼的攻击性高亮追着跑的大鱼会明显增加压迫感。我个人实际做完后的最大体会是这个游戏看起来简单但它几乎每个环节都有真正的技术含量——数值曲线、状态机、碰撞检测、性能优化每一项都能嚼出不少东西。如果你也正想找一个能完整做下来、又不至于在美术上卡住的练手项目“大鱼吃小鱼”绝对值得一试。