从零构建开源“大鱼吃小鱼”:Canvas游戏开发的完整实战指南

发布时间:2026/10/14 15:40:58
从零构建开源“大鱼吃小鱼”:Canvas游戏开发的完整实战指南 如果让我从一堆小游戏企划里挑一个既能练手、又适合开源分享的web小游戏“大鱼吃小鱼”绝对排前三。原因很简单它看起来功能不多但本质上覆盖了游戏开发最核心的几块拼图——Canvas图形渲染、碰撞检测、AI行为、游戏循环、移动端兼容甚至手感调优。而且它天然有“数值成长线”吃一条鱼变大一点再吃更大的鱼这种反馈感会让第一版跑通之后特别有成就感。这篇文章就把我当初做这个开源项目的完整思路、技术选型、代码架构和踩坑记录都摊开来聊一聊。不管你是刚开始学Canvas游戏开发还是已经写过几个Demo想找一个完整的开源项目研究这篇文章应该都能给你一点东西。我会尽量把“为什么这样做”也讲清楚不仅仅是贴代码。1. 从零规划一个可复刻的“大鱼吃小鱼”项目动机与技术选型1.1 为什么做Web版而不是App版发布门槛和迭代速度很多人第一反应是这种游戏不该做成手机App吗手感更好还能上应用商店。但实际做过一遍你会发现Web版的价值完全被低估了。我当时的真实情况是想做一个能随时打开就玩的休闲小游戏同时想验证一下自己前端Canvas的水平。如果用原生App的方式光签名打包、适配各种屏幕、上架审核就要花掉不少时间而且一旦更新个数值用户还得重新下载。Web版只需要浏览器打开分享一个链接就能玩微信里也能直接跑。开发迭代很快上午改完代码下午朋友就能玩到新版。另外一个很现实的原因是开源友好。Web项目的源码结构对新手来说非常透明打开浏览器按F12CSS、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游戏时给代码里所有重要的数值都加上注释并标明“改这个参数会感觉怎么样”。比如“鱼的转向速度改大会让鱼群更灵活改小更呆滞”。这些注释当时帮了我很多忙几个月后自己回来看代码也不会忘了当初为什么设这个值。开源出去别人也能更快上手。这大概就是开源项目最有意思的部分——代码不只是写给机器看的更是写给下一个开发者看的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询