如果让我从一堆小游戏企划里挑一个既能练手、又适合开源分享的web小游戏,“大鱼吃小鱼”绝对排前三。原因很简单:它看起来功能不多,但本质上覆盖了游戏开发最核心的几块拼图——Canvas图形渲染、碰撞检测、AI行为、游戏循环、移动端兼容,甚至手感调优。而且它天然有“数值成长线”,吃一条鱼变大一点,再吃更大的鱼,这种反馈感会让第一版跑通之后特别有成就感。
这篇文章就把我当初做这个开源项目的完整思路、技术选型、代码架构和踩坑记录都摊开来聊一聊。不管你是刚开始学Canvas游戏开发,还是已经写过几个Demo想找一个完整的开源项目研究,这篇文章应该都能给你一点东西。我会尽量把“为什么这样做”也讲清楚,不仅仅是贴代码。
1. 从零规划一个可复刻的“大鱼吃小鱼”:项目动机与技术选型
1.1 为什么做Web版而不是App版:发布门槛和迭代速度
很多人第一反应是:这种游戏不该做成手机App吗?手感更好,还能上应用商店。但实际做过一遍你会发现,Web版的价值完全被低估了。
我当时的真实情况是:想做一个能随时打开就玩的休闲小游戏,同时想验证一下自己前端Canvas的水平。如果用原生App的方式,光签名打包、适配各种屏幕、上架审核就要花掉不少时间,而且一旦更新个数值,用户还得重新下载。Web版只需要浏览器打开,分享一个链接就能玩,微信里也能直接跑。开发迭代很快,上午改完代码,下午朋友就能玩到新版。
另外一个很现实的原因是:开源友好。Web项目的源码结构对新手来说非常透明,打开浏览器按F12,CSS、JavaScript、Canvas绘制逻辑全部可见,学习成本比一个编译打包后的App低多了。
1.2 技术栈定案:Canvas 2D为主、DOM界面为辅
这个项目的技术栈其实非常克制。玩家看到的海洋、鱼群、吃鱼特效都是用Canvas 2D画的,而游戏菜单、得分显示、结算面板这些界面元素直接用HTML和CSS写在页面上。
为什么不整个游戏都用Canvas?原因是这类休闲小游戏的UI交互远比游戏画面复杂,比如按钮、弹窗、滑杆这些DOM元素天生自带点击事件和布局能力,用Canvas重新实现一遍很费劲,而且移动端适配要写一堆坐标换算。反过来,游戏本身的海底世界、鱼群游动这些东西用DOM是画不出来的,必须交给Canvas。所以最合理的分工就是:Canvas负责“动的东西”,DOM负责“静态界面和交互控件”。
画布尺寸的处理上,我建议不要写死,而是根据窗口自适应。核心逻辑是取window.innerWidth和window.innerHeight,然后按设备的像素比做一次缩放,否则在高分屏上画面会发虚。代码大致是:
const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const DPR = window.devicePixelRatio || 1; function resizeCanvas() { const w = window.innerWidth; const h = window.innerHeight; canvas.width = w * DPR; canvas.height = h * DPR; canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); }这段代码几乎是我做所有Canvas项目都会先写的基础设施,不是只针对这个游戏。别小看这几行,很多画面模糊、边缘锯齿的问题都是从这里来的。
1.3 开源的意义:给学游戏开发的人一份完整可跑的“最小范例”
聊到开源,先说我个人的立场:开源一个这种体量的项目,不是为了展示代码多花哨,而是为了给后来的人一个“完整可跑”的参照系。
网上很多教程会教你画一条鱼、写一个碰撞函数,但很少告诉你整个游戏怎么组织文件、怎么把菜单和游戏场景串起来、怎么在手机上处理触摸事件。对于新学者来说,卡住他们的往往不是某个函数不会写,而是“整个项目怎么串起来”。
所以我从一开始就确定了这个开源项目的目标:目录结构要清晰,代码要尽量一个文件干一件事,关键逻辑要有注释,并且默认支持鼠标和触摸。这样任何一个拿到源码的人,本地起一个静态服务器就能玩,改改数值就能感受到游戏变化。这才是“开源范例”的价值。
2. 游戏核心循环与整体架构:不是游戏循环,是“演出调度”
2.1 游戏循环的两种实现思路
游戏本质就是一个“每帧更新再绘制”的死循环。但这里有一个新手很容易纠结的问题:用requestAnimationFrame还是setInterval?
我的答案是:只能用requestAnimationFrame。浏览器会在下一次重绘之前调用这个回调,保证画面刷新和屏幕的刷新率同步,掉帧的时候它会自动降频。setInterval做不到这么聪明,它的最小间隔和实际绘制时机是脱节的,很容易出现画面撕裂或者动画不流畅。
但仅仅用requestAnimationFrame会有一个隐患:不同设备的刷新率不一样,60Hz和120Hz的手机跑同一个游戏,速度会明显不同。这时候就需要引入delta time,也就是上一帧到现在过去了多少毫秒,所有移动距离都用“速度 * 时间差”来计算,而不是“速度 * 帧数”。
比如鱼游动时的位置更新就不能写死每一帧移动多少像素:
// 错误示范:高刷屏上鱼会加速 fish.x += fish.speed; // 正确做法:速度按秒计算,乘上帧间隔 fish.x += fish.speed * dt; // dt单位是秒这样游戏在60Hz设备上和在120Hz设备上,鱼的绝对移动速度是一致的。写完游戏循环,项目里自然会有update(dt)和render()这两个核心函数,前者负责计算所有逻辑,后者在计算完之后统一绘图。
2.2 分层设计:逻辑层、渲染层、界面层的边界
在一个很小的游戏项目里谈“分层”,听上去有点正经,但这个真的不是装模作样。一个“大鱼吃小鱼”游戏,如果你把所有代码全写在一个文件里,写到300行的时候还觉得挺顺,写到1000行的时候你会发现改一个吃鱼判定,不知道哪里就冒出一个bug。
我当时的分层方式是:
- 逻辑层:管理鱼的位置、速度、大小、是否被吃、AI行为,这些数据不关心用户看到什么。
- 渲染层:只负责把逻辑层的数据画到Canvas上,不管碰撞、不管AI。
- 界面层:负责开始菜单、游戏结束面板、分数提示。
这样做的好处很具体:当我想修改渲染方式,比如从“绘制三角形鱼”改成“绘制带纹理的鱼”,我只动渲染层函数,鱼群游动逻辑一行都不会被波及。当我想调整鱼的AI,也只需要在逻辑层修改决策函数。
2.3 场景与状态机:菜单、游戏中、结算
另外,哪怕再小的游戏,我都建议用一个简单的状态机来区分“正在显示菜单”、“正在游戏中”、“已经结束”这三种状态。否则你会在游戏结束的瞬间发现鱼还在继续游,或者点了开始按钮却无法阻止旧世界的动画。
可以这样定义:
const GameState = { MENU: 'menu', PLAYING: 'playing', GAMEOVER: 'gameover' };每帧循环里根据currentState决定执行哪些逻辑。比如菜单状态只渲染背景和鱼群,不计算玩家实体;游戏结束状态只监听“再来一局”按钮的点击事件。状态机的价值在于把“状态的切换”显式化,省掉了大量的if嵌套判断,代码的逻辑边界一下就清晰了。
3. 无边际海洋地图:移动、拖拽与镜头跟随的取舍
3.1 无边界地图的几个流派
“大鱼吃小鱼”这类游戏有一个很微妙的设计点:玩家控制的鱼是可以向任意方向游的,但背景又是无限延伸的海洋,不可能真的加载一张无限大的地图。这就有几种做法:
第一种是“小地图循环”:让海底纹理在四个方向都可以平铺,鱼在固定的画布范围内游动,游到边界就绕回对侧。优点是实现简单,缺点是玩家走不出去,会有一种无尽循环的单调感。
第二种是“镜头跟随”:玩家鱼在一个抽象的大地图里移动,摄像机始终追着玩家鱼跑。地图虽然有限大,但设计得足够大,玩家在其中探索时感受不到边界。缺点是镜头移动会让未参与渲染的鱼需要动态筛选,增加了一些性能消耗。
第三种是海面拖拽:让玩家鱼的坐标固定,画面整体反向移动,玩家通过在屏幕上拖动来“移动整个世界”。这种方案在移动端很常见,因为手机玩家习惯了用手指划来划去控制视点。
我最后选择的是“镜头跟随 + 模拟拖拽”的组合方案:逻辑上玩家鱼在几万像素见方的大地图里游动,但屏幕上始终以玩家鱼为中心,同时在目标点加入平滑跟随,让镜头不会生硬地“钉”在鱼身上。这样既保留了探索感,又避免了画面抖动。
3.2 镜头坐标与实体坐标分离设计
有了镜头,坐标系统就要拆成两套:一套是世界的绝对坐标,一套是屏幕坐标。
世界坐标用来计算鱼群的实际位置、碰撞和AI,这个坐标范围是很大的,可能是0到10000的数值。屏幕坐标则是把“世界坐标”减去“镜头位置”得到的偏移量:
function worldToScreen(worldX, worldY, camera) { return { x: worldX - camera.x, y: worldY - camera.y }; }渲染时所有鱼都先做一次坐标转换再绘制,而碰撞检测依然基于世界坐标。这个设计一开始可能觉得麻烦,但我实际做下来发现它极其必要。尤其是当你想增加“玩家鱼逐渐变大,镜头逐渐想拉远”的效果时,你会发现坐标分离让你可以独立控制镜头比例,而不会把鱼群的位置数据搞乱。
3.3 拖拽手感调优:惯性、减速、边界处理
玩家在手机上玩的时候,用手指在屏幕上滑动,希望整个世界能顺滑地跟着手指走。这里面最容易让玩家觉得“手感差”的两个点是:拖拽跟不上手指、松手后瞬间停下。
解决第一个点,需要定义“目标镜头位置”,用手指的拖动增量实时更新目标,再用每帧往目标插值的方式让镜头慢慢追上去。这个插值系数通常在0.08到0.12之间,太大会让镜头跟着手指乱跳,太小会让操作发闷。解决第二个点,需要引入惯性:松手时记录手指的最后速度,然后让镜头带上这个速度衰减滑动,类似一个摩擦系数。
我给镜头做的简化惯性代码如下:
// 手指松开时读取最后的速度 let velocityX = lastMoveDeltaX / dt; // 每帧对速度做衰减,并移动镜头目标 velocityX *= 0.92; camera.targetX += velocityX * dt * 60;这个0.92的衰减系数是我试过的几个值里手感最舒服的,不会一松手就停,也不会滑出去太远收不回来。如果你们自己做了手感测试之后感觉不对,可以从0.85到0.95之间调整,这个是典型的“多试才有感觉”的参数。
4. 鱼群的AI与碰撞判定:让每条鱼都有自己的“脑子”
4.1 目标选择与威胁判定
要让游戏看起来“活”,鱼群不能只做无规则布朗运动。每条鱼AI的核心是回答三个问题:
- 我该朝哪里游?
- 周围有没有比我小的鱼能吃?
- 周围有没有比我大的鱼在追我?
为了不做太复杂的寻路系统,我给每条鱼维护了一个“目标行为”的状态,状态每0.5秒左右重新刷一次。如果周围有比我小30%以上的鱼,我就尝试追踪;如果周围有比我大30%以上的鱼,我就反向逃跑;如果又大又小的鱼同时存在,逃跑优先。这个“逃跑优先”的原则其实很符合直觉——活着才能有输出。
至于“谁是谁的威胁”,最简单的模型就是比大小。用鱼的半径来代表体型,比我大的,恐怖程度和体型差成正比;比我小的,则进入捕食目标列表,选最近的作为目标。这个模型看起来朴素,但已经足够撑起一整个休闲游戏的可玩度了。
4.2 鱼群行为:游动、转向、逃窜
明确了目标之后,就要让鱼动起来。最基本的行为是漫游:给每条鱼一个基准方向,然后每隔一段时间加一个随机扰动,让游动轨迹看起来有“自主感”。
当目标是食物时,鱼会转向目标,但为了避免所有鱼都挤到一个点上,每条鱼的转向角度会带一点随机偏移。当处于逃跑状态时,鱼的转向速度会明显加快,速度也会提升到平时的1.5倍左右,这是做“紧张感”的关键参数。
关于肢体动画,鱼的尾巴摆动可以用一个正弦波控制角度,游得越快摆动频率越快。这个细节对视觉观感提升巨大。很多新手代码会把鱼画成静止的三角形,结果看起来像纸片飘在水里。加上尾巴摆动之后,整个画面立刻活了起来。
画一条鱼的伪代码大致是这样:
function drawFish(ctx, fish) { ctx.save(); ctx.translate(screenX, screenY); ctx.rotate(fish.direction); const tailAngle = Math.sin(fish.time * fish.swimSpeed) * 0.3; // 先画尾巴 ctx.moveTo(-fish.bodyLength / 2, 0); ctx.lineTo(-fish.bodyLength / 2 - 8, -6 + tailAngle * 8); ctx.lineTo(-fish.bodyLength / 2 - 8, 6 + tailAngle * 8); // 再画椭圆身体 ctx.ellipse(0, 0, fish.bodyLength / 2, fish.bodyHeight / 2, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }4.3 碰撞判定的取舍:圆形判定与吃与被吃的临界值
碰撞检测在这个游戏里其实不需要多“精确”,反而越精确越容易让玩家火大。你想,两条鱼边界只是擦了一下就被判定吃掉,玩家会觉得系统在耍他。所以这里我用的不是像素级碰撞,而是圆形判定:给每只鱼一个碰撞半径,直接算两个圆是否相交。
判断吃与被吃时,做一个简单的比较:如果我的半径比对方大,且距离小于两者半径差,那就是我吃它。这个“半径差”的设定实际上意味着你的鱼必须“涵盖”到对方相当一部分才算吃掉,给视觉留出了缓冲空间。
具体代码不复杂,但要注意一个细节:比较大小不能用“我的半径 > 对方的半径”,而是要加上一个阈值,比如myRadius > otherRadius * 1.2。否则你在游戏中会遇到一种情况:两只差不多大的鱼互相碰一下就互相吃,玩家会觉得很迷惑。加上20%的体型优势阈值,感觉就明确很多了。
碰撞检测每帧都要做,但每组鱼都算一遍会随着鱼的数量上升而爆炸。一个优化是先用“网格分区”把地图切成若干区域,只检测同区域以及相邻区域内的鱼。对于这个小项目,鱼的总量控制在30只以内时,双重循环直接遍历也完全跑得动,所以我一开始并没有过度优化,只在鱼的数量真正上来之后才加网格。这也是我想说的:性能优化不是一上来就做,而是先跑起来、量起来,才发现瓶颈在哪。
5. Canvas性能优化:从“可以跑”到“流畅跑”的三个坎
5.1 requestAnimationFrame与delta time的配合
前面提过游戏循环用requestAnimationFrame,但真正落地时还有几个细节。
第一,所有鱼的位置更新、镜头跟随、特效粒子都必须以dt为基准,否则高刷屏和低刷屏体验差距巨大。第二,不能让requestAnimationFrame在后台标签页继续空转,用document.hidden属性判断页面可见性,不可见时暂停循环,省电也省CPU。第三,渲染时尽量避免在每帧里改动画布尺寸,canvas.width = w这种操作很昂贵,如果分辨率不变就永远不要重复赋值。
浏览器自带的Developer Tools里有一个“渲染”面板,可以开启“帧渲染模式”看到每帧渲染耗时。我优化的目标很简单:在手机上保持帧率不低于50fps,如果哪一帧掉到30以下,就回去看是绘制调用太多还是碰撞计算太慢。
5.2 对象池:避免GC卡顿
休闲小游戏最容易出现的卡顿不是计算量超大,而是垃圾回收。当鱼被吃掉时,如果把鱼对象直接设为null,然后每帧又新建一堆鱼,JavaScript引擎会频繁触发垃圾回收,导致游戏每隔几十秒卡顿一下。
解决方法是对象池。预先创建一个数组,存50条鱼的模板对象。需要新鱼时从池里取一条,重新初始化它的位置、大小、速度;鱼被吃掉时也不销毁对象,而是把它标记为“可用”,放回池里。
function getFishFromPool() { let fish = fishPool.find(f => f.dead); if (!fish) { fish = createNewFish(); fishPool.push(fish); } fish.dead = false; return fish; }这种池化思想在很多游戏引擎里都有现成实现,但在原生Canvas项目里更值得手动写一遍。改了之后,我在Android低端机上测试,原先每两分钟出现一次的卡顿基本消失了。
5.3 分帧渲染与移动端的降级方案
还有一个性能优化思路是“不是每个东西都要每帧重绘”。背景海底纹理可以离屏预渲染成一张大图,每帧只要用drawImage把对应区域“贴”上去即可,而不是每帧重新绘制几十个气泡和石头。鱼身上的渐变阴影、鱼鳍的高光等效果也可以预先在离屏Canvas里画好,再用drawImage绘制整体贴图。
如果用户的设备性能实在不行,我提供了一个“低画质模式”:关闭粒子特效、减少最多同时存活的鱼数量、将鱼的纹理由渐变降级为纯色填充。这个模式不是默认开启,而是我通过每帧的实际耗时来判断,比如连续20帧渲染超过18毫秒,就自动切换。很多开源项目会加一个手动画质选项,但自动降级对普通玩家更友好。
6. 开源之后:目录结构、版权声明与二次开发建议
6.1 仓库目录怎么组织才友好
一个开源项目最容易被忽略的是目录结构。很多个人项目把代码全堆在根目录,文件叫aaa.js、test2.js,这种仓库即使开源了,也没人愿意看。
我最后整理出来的目录结构是这样的,简单但划分清楚:
/ ├── index.html # 页面骨架和画布元素 ├── css/ │ └── style.css # 界面层样式 ├── js/ │ ├── main.js # 游戏初始化与循环 │ ├── camera.js # 镜头跟随与惯性 │ ├── fish.js # 鱼的数据结构、动画绘制 │ ├── ai.js # 鱼群AI逻辑 │ ├── collision.js # 碰撞检测 │ ├── pool.js # 对象池 │ └── ui.js # 界面状态切换与按钮事件 └── README.md # 项目说明与运行方法README.md我会写清楚三件事:怎么在本地运行(静态服务器或直接双击)、怎么修改难度参数(鱼的速度、体型阈值等)、以及截图展示游戏效果。一个好README的价值不亚于好代码,它决定了别人点进仓库之后会不会真的把项目跑起来。
6.2 开源协议的常见误区
开源不是“我把代码挂到网上”就完了。如果你希望别人可以自由使用、修改、甚至商用,需要明确选择一个开源协议。
我选的是MIT协议,原因很简单:宽松、友好、几乎没有任何限制。同时我在代码文件头部和LICENSE文件里都写清楚了作者信息和年份,这样即使别人完全改写了代码,也有一份归属上的记录。如果你不希望你的代码被别人闭源商用,那应该考虑GPL类协议;如果只是让朋友看看不打算让别人复制,那一开始就不要叫“开源”,用“私有仓库”更合适。
这个点很容易被做开源项目的新手忽略,但稍微认真对待一下,后面能省掉很多糟心事。
6.3 给想继续做的人:三个可扩展方向
这个项目写完开源之后,我自己还给未来的版本留了几个明确的可扩展方向,如果有人fork过去了,这几个方向都是很好的突破口:
第一个方向是增加鱼的种类和技能系统。比如灯笼鱼可以照亮更大范围,刺豚可以反弹一次攻击,不同鱼种有不同特性,这会带来完全不一样的策略深度。
第二个方向是排行榜和云存档。目前得分只存在本地,如果接入一个后端存储,加上每日挑战和排行榜,一个小品游戏立刻就能变成有留存机制的产品。这也是从“技术Demo”走向“可运营项目”的关键一步。
第三个方向是加入合成进化的玩法。大鱼吃小鱼这个品类可以借鉴合成游戏的思路,吃掉同类型的鱼后进化成新外观的鱼,带给玩家的长期目标感会更强。这个方向改起来也很有趣,因为现有的鱼数据结构和AI逻辑都已经分离,新增“进化形态”不会牵一发动全身。
如果你拿到这个项目之后想改点什么,我个人的建议是从参数调优开始,而不是直接加功能。你把玩家初始体型改成原来的一半,把AI鱼的速度改成1.2倍,游戏的整体紧张感就完全不一样了。这种改动让你先感知到一个游戏是怎么通过数值来调情绪的,然后再动架构层面的东西会更有体会。
最后再分享一个实际操作中的小技巧:开发这类Canvas游戏时,给代码里所有重要的数值都加上注释,并标明“改这个参数会感觉怎么样”。比如“鱼的转向速度,改大会让鱼群更灵活,改小更呆滞”。这些注释当时帮了我很多忙,几个月后自己回来看代码,也不会忘了当初为什么设这个值。开源出去,别人也能更快上手。这大概就是开源项目最有意思的部分——代码不只是写给机器看的,更是写给下一个开发者看的。