如果你小时候在网页上玩过“大鱼吃小鱼”,应该还记得那种既紧张又爽快的体验:拼命寻找比自己小的鱼,又时不时瞥见一条大一圈的家伙缓缓游来,手心冒汗。这个经典玩法放到今天依然不过时,它天然具备快速反馈和成长快感,很适合拿来当作游戏开发的练手项目。我最近从零做了一版网页版的“大鱼吃小鱼”,过程不算长,但遇到不少有价值的问题。这篇文章就围绕这个自研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}`; }在每次逻辑更新时,先遍历鱼群生成Map<gridKey, 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鱼的攻击性,高亮追着跑的大鱼会明显增加压迫感。我个人实际做完后的最大体会是:这个游戏看起来简单,但它几乎每个环节都有真正的技术含量——数值曲线、状态机、碰撞检测、性能优化,每一项都能嚼出不少东西。如果你也正想找一个能完整做下来、又不至于在美术上卡住的练手项目,“大鱼吃小鱼”绝对值得一试。
如果你小时候在网页上玩过“大鱼吃小鱼”,应该还记得那种既紧张又爽快的体验:拼命寻找比自己小的鱼,又时不时瞥见一条大一圈的家伙缓缓游来,手心冒汗。这个经典玩法放到今天依然不过时,它天然具备快速反馈和成长快感,很适合拿来当作游戏开发的练手项目。我最近从零做了一版网页版的“大鱼吃小鱼”,过程不算长,但遇到不少有价值的问题。这篇文章就围绕这个自研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}`; }在每次逻辑更新时,先遍历鱼群生成Map<gridKey, 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鱼的攻击性,高亮追着跑的大鱼会明显增加压迫感。我个人实际做完后的最大体会是:这个游戏看起来简单,但它几乎每个环节都有真正的技术含量——数值曲线、状态机、碰撞检测、性能优化,每一项都能嚼出不少东西。如果你也正想找一个能完整做下来、又不至于在美术上卡住的练手项目,“大鱼吃小鱼”绝对值得一试。