☰
微信小游戏《果蔬去哪了》源码解析:Canvas渲染与碰撞检测实践
2026/10/12 4:08:03 网站建设 项目流程

简介:面向想入门微信小游戏开发的程序员和休闲益智类游戏爱好者,《果蔬去哪了》完整源码包集中展示了微信小游戏从框架搭建到玩法落地的实现路径。压缩包共738个文件、6.79MB,包含103个js脚本、293个json配置、219张png图片、87个meta元数据,以及动画atlas图集、plist序列帧、map地图配置、音效mp3等;源码中可学习WXML/WXSS构建界面、JavaScript驱动游戏逻辑、触摸事件监听与响应、CSS3动画、计分与关卡控制、资源预加载与释放、本地数据缓存、错误捕获、网络通信和渲染性能优化等关键环节。目前已有615人学习下载。美术资源按主题分类存放,图片与动画序列风格统一,可直接替换到项目中测试效果;研读代码能掌握消除类玩法的计分、关卡与点击判定逻辑,以及工程组织方式,配合完整的项目结构,适合作为课程设计、个人练手或二次开发的参考样例。

1. 微信小游戏《果蔬去哪了》源码资源:一个能直接跑起来的轻量级项目

做微信小游戏开发的人应该都有这种体会:官方文档和 Demo 一大堆,但真正能落到自己项目里、能直接照着改的完整源码反而是稀缺品。这份《果蔬去哪了》的线上游戏源码及资源,就是一个典型的轻量级休闲小游戏完整实现——玩法是果蔬从屏幕上方掉落,玩家通过点击或滑动来收集指定目标、避开障碍物,核心机制不复杂,但代码结构、资源组织方式、Canvas 绘制流程和碰撞检测逻辑都是完整的,拿来当模板做原型验证或者学习小游戏 API 调用路径都很合适。

它解决的问题很直接:省掉你从零搭项目、抠资源格式、调试 touch 事件兼容性的时间。适合三类人——刚接触微信小游戏、想看看真实项目代码长什么样的新手;需要快速出一个可玩 Demo 去验证创意的独立开发者;以及想研究 Canvas 帧循环和碰撞检测写法的人。源码和资源文件是分离的,图片和动画素材单独打包,换皮改玩法都方便,这一点在后面的章节里会详细拆。先说结论:这个项目不值得你花大精力去研究架构,但非常适合当「骨架」来用。

2. 代码结构拆解:主循环、场景管理与碰撞检测的落地写法

2.1 从 game.js 入口看微信小游戏的启动流程

微信小游戏的入口文件和网页端有很大区别,没有 DOM、没有 document,一切从game.js开始。这个文件在项目里只做一件事:创建主场景实例并启动渲染循环。看代码你会发现它的写法很典型——先import游戏主类,然后实例化并调用启动方法,全局的canvas对象由微信运行时自动注入。

// game.js — 小游戏入口文件 import Main from './js/main' // 实例化主游戏类并启动 const game = new Main() game.start()

这里的逻辑说明很简单:Main类内部负责初始化全局变量、注册触摸事件、启动帧循环。值得注意的参数是Main构造函数里通常会做 canvas 尺寸适配,用window.innerWidth和window.innerHeight获取屏幕实际像素尺寸,而不是写死宽高。常见做法是在启动前先调用适配函数,否则不同机型上画面会拉伸或偏移。

2.2 帧循环:requestAnimationFrame 与定时器的取舍

小游戏的渲染循环有两种实现路径:用requestAnimationFrame或者用setInterval。这份源码用的是前者,我在实际项目里也推荐这种做法,原因很简单——requestAnimationFrame由小程序运行时接管,在页面不可见时会自动暂停,不浪费 CPU;而setInterval即使页面切后台了还在跑,对性能是白白的消耗。

// 帧循环核心代码 — 位于 Main 类中 start() { // 绑定循环函数,确保 this 指向正确 this.loop = this.loop.bind(this) // 启动帧循环 cancelAnimationFrame(this.animationId) this.animationId = requestAnimationFrame(this.loop) } loop() { // 1. 更新游戏状态:果蔬下落位置、碰撞检测、得分判定 this.update() // 2. 重新绘制整个画面 this.render() // 3. 递归调用,形成持续帧循环 this.animationId = requestAnimationFrame(this.loop) }

参数说明:animationId是每次requestAnimationFrame返回的 ID,用来在需要停止循环时调用cancelAnimationFrame取消。update和render分离是这类项目的基本功——前者负责逻辑、后者负责绘图,千万别混在一起,否则调试碰撞位置时会疯掉。我见过不少新手把碰撞检测写进render里,结果帧率一变,判定就飘了。

2.3 碰撞检测:矩形碰撞的边界处理技巧

果蔬小游戏的碰撞检测用的是矩形碰撞,不涉及像素级检测,这对休闲游戏来说完全够用。源码里的碰撞函数不是简单地拿两个矩形做交集判断,还额外处理了一个容易出问题的细节:碰撞后物体不能陷入对方体内。

// 矩形碰撞检测 — 带位移修正 checkCollision(a, b) { // 先判断矩形是否相交 if (a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y) { // 相交后计算重叠深度,把物体推出碰撞区域 // 这里以物体 a 为主动方,向上回退 const overlapY = (a.y + a.height) - b.y a.y -= overlapY return true } return false }

这里有个参数设计的细节值得留意:碰撞函数里直接修改了a.y,而不是只返回布尔值。这么做的好处是调用方不需要再写一套回退逻辑,坏处是函数职责不纯粹。我在自己的项目里一般会拆成两个函数——isCollide只做检测,resolveCollision处理位移,代码可读性更好,也方便后续加不同形状的碰撞体。新手抄这份源码的写法没问题,但要注意:碰撞后的回退方向要和掉落方向一致,否则果蔬会穿模。

3. 图片与动画资源:从加载到渲染的完整链路

3.1 资源加载模块:预加载与进度回调的实现

小游戏里图片资源不能直接用<img>标签,必须通过Image对象的实例来加载,而且所有资源必须在用到之前加载完,否则画出来是空白。这份源码的资源加载模块做得比较完整,支持预加载列表传入、单张图片加载完成回调和全部完成回调。

// 资源加载器 — 预加载所有图片 class ResourceLoader { constructor(resourceList) { this.resourceList = resourceList this.loadedCount = 0 this.totalCount = resourceList.length } load(onProgress, onComplete) { this.loadedImages = {} this.resourceList.forEach(item => { const img = new Image() img.onload = () => { this.loadedCount++ this.loadedImages[item.name] = img // 进度回调,参数为已加载数量 onProgress && onProgress(this.loadedCount, this.totalCount) // 全部加载完成 if (this.loadedCount >= this.totalCount) { onComplete && onComplete(this.loadedImages) } } img.onerror = () => { console.error('资源加载失败:', item.src) } img.src = item.src }) } }

逻辑说明:这里用loadedCount / totalCount计算进度,loadedImages以资源名为键存储图片实例,方便后续按名字拿图。参数上要注意item.name和item.src是约定好的字段,加载列表由外部传入,换皮改资源时只需要替换列表内容,不用碰加载器本身。我一般还会加一个超时保护——超过 10 秒还没加载完就主动报错,避免弱网环境下白屏卡死。

3.2 Canvas 绘制果蔬:drawImage 的参数坑

果蔬在屏幕上显示靠的是CanvasRenderingContext2D的drawImage方法。这个方法有几种重载,源码用的是九参数版本:指定源图裁剪区域和目标绘制区域,这样同一张图片可以裁剪出多个果子。但这里有个非常容易翻车的点:drawImage的坐标是相对于 canvas 原点,而果蔬对象自身的坐标有中心点和左上角两种表示方式,搞混了画出来的果子会偏移半个身位。

// 绘制单颗果蔬 drawFruit(ctx, fruit) { // 参数依次为:图片、源图x、源图y、源图宽、源图高、 // 目标x、目标y、目标宽、目标高 ctx.drawImage( this.img, this.frameX * this.frameWidth, // 从雪碧图中裁剪起始x this.frameY * this.frameHeight, // 从雪碧图中裁剪起始y this.frameWidth, // 裁剪宽度 this.frameHeight, // 裁剪高度 fruit.x - fruit.width / 2, // 目标位置x — 用中心点定位 fruit.y - fruit.height / 2, // 目标位置y fruit.width, // 绘制宽度 fruit.height // 绘制高度 ) }

注意最后两行的写法:目标坐标用的是fruit.x - fruit.width / 2,说明果蔬对象的x和y是中心坐标,不是左上角。这种约定在碰撞检测和下落动画里都要保持一致。如果你把中心坐标当作左上角坐标直接用,画面里果蔬会整体向右下方偏移,而且越靠下越明显。老手一般都会在设计数据结构时统一用中心坐标,碰撞检测和绘制都不用换算。

3.3 帧动画:雪碧图切割与动画索引

果蔬掉落的动画不算复杂,但源码里还是用雪碧图做了简单的帧动画——一个果实有多个旋转或缩放帧,循环播放。实现原理是定时切换frameX和frameY来确定当前帧在雪碧图中的位置。

// 帧动画更新 — 每帧调用 updateAnimation(fruit, deltaTime) { // 累加动画时间 fruit.animTimer += deltaTime // 每 100 毫秒切换一帧 if (fruit.animTimer >= 100) { fruit.animTimer -= 100 // 帧索引循环递增,frameCount 为总帧数 fruit.currentFrame = (fruit.currentFrame + 1) % this.frameCount // 计算当前帧在雪碧图中的列位置 fruit.frameX = fruit.currentFrame % this.frameCols fruit.frameY = Math.floor(fruit.currentFrame / this.frameCols) } }

这里的deltaTime是帧间隔时间,用毫秒为单位。用% this.frameCount实现循环播放,用frameCols把一维帧索引换算成二维雪碧图坐标。参数上需要注意:如果雪碧图不是横排而是网格排布,frameY的计算就不能省。这块我觉得源码最大的参考价值在于动画时间累加的方式——不是每帧固定切帧,而是按真实经过的时间累积,帧率不稳定时动画也不会忽快忽慢。

4. 核心玩法落地:果蔬生成、分类下落与计分反馈

4.1 果蔬生成逻辑:随机性与难度曲线的平衡

果蔬从顶部往下掉,每帧生成的间隔不能是固定值,否则游戏节奏会显得死板。源码里的生成器用了一个随机间隔加动态调整的策略:基础间隔随游戏时间逐步缩短,同时叠加一个随机抖动值。这个做法很务实,我拆过不少小游戏项目,难度曲线做得好不好直接影响玩家留存。

// 果蔬生成器 — 难度递增的随机生成 generateFruit(gameTime) { // 基础间隔从 800ms 开始,随游戏时间逐渐缩短到 300ms const baseInterval = Math.max(300, 800 - gameTime * 0.5) // 叠加 ±150ms 的随机抖动,让生成节奏有呼吸感 const randomOffset = (Math.random() - 0.5) * 300 this.nextGenerateTime -= (baseInterval + randomOffset) // 时间到了,生成一颗果蔬 if (this.nextGenerateTime <= 0) { // 重置下一次生成时间 this.nextGenerateTime = baseInterval + randomOffset // 随机选取果蔬类型,权重后面按玩法调整 const typeIndex = Math.floor(Math.random() * this.typeList.length) this.spawnFruit(typeIndex) } }

逻辑说明:gameTime是累计游戏时间毫秒值,0.5是难度递增系数,300是最短间隔下限。这样做的好处是前期节奏宽松、玩家容易上手,后期间隔短但不会突破下限导致无法反应。我一般会把难度系数单独提成配置文件,方便不同关卡复用。吐槽一句:源码里nextGenerateTime做的是累减,有些人习惯累加,两种写法没本质区别,但别混用,不然生成节奏会乱。

4.2 果蔬分类与掉落速度:用随机权重控制目标分布

游戏里不是所有果蔬都算得分项,有的要收集、有的要躲避,源码里用一个权重表来区分不同类型果蔬的出现概率。这种做法的核心价值在于:概率不是一成不变的,不同游戏阶段可以动态调整权重,让目标果蔬出现频率变化,造成玩法压力。

// 配置不同类型果蔬的权重 — 动态调整出现概率 const fruitConfig = { apple: { weight: 30, score: 10, speed: 2.0, image: 'apple.png' }, banana: { weight: 20, score: 15, speed: 2.5, image: 'banana.png' }, bomb: { weight: 15, score: -20, speed: 3.0, image: 'bomb.png' }, golden: { weight: 5, score: 50, speed: 1.8, image: 'golden.png' } } // 按权重随机选取类型 — 返回类型键名 function pickFruitType(config) { // 计算总权重 const totalWeight = Object.values(config).reduce((sum, item) => sum + item.weight, 0) // 生成 0 到总权重之间的随机数 let random = Math.random() * totalWeight // 遍历类型,减去权重直到命中区间 for (const [key, item] of Object.entries(config)) { random -= item.weight if (random <= 0) { return key } } return 'apple' // 兜底返回 }

参数说明:speed是每帧下落的位移量,单位是像素,配合帧循环使用;score是正负分,炸弹是负分,用来增加游戏的策略性——不是手快就行,还得判断该不该接。权重算法的逻辑很简单:把总权重当作一条线段,随机落点落在哪个区间就选哪个类型。这里也提示一个实际项目里的经验:千万不要把score和weight写在同一个配置对象里却用不同的命名风格,这份源码还算统一,但有些仿写版本里字段名五花八门,改起来头大。

4.3 计分与反馈:得分动画的绘制策略

计分系统的实现不复杂,但反馈效果做得好不好,玩家感受差别很大。源码里的计分逻辑分两层:一是数值累加,二是屏幕上的浮动得分动画——果子被接到时,原地飘出一行「+10」的字样,然后逐渐上移消失。这个细节很加分,因为玩家需要即时反馈来确认操作有效。

// 得分反馈 — 浮动文字对象 createScorePopUp(x, y, score) { return { x: x, y: y, score: score, life: 100, // 动画持续时间(帧数) maxLife: 100, vy: -1.5, // 每秒向上漂移的像素量 update() { this.y += this.vy // 向上移动 this.life -= 1 // 生命值递减 }, isDead() { return this.life <= 0 } } }

绘制浮动文字时要设置ctx.font和ctx.fillStyle,并根据life / maxLife计算透明度渐隐效果。源码用life做倒计时而不是时间戳,配合帧循环每帧递减 1,实现起来干净利落。参数上vy是负值表示向上移动,正值会变成向下掉。这里有个小坑:如果 canvas 的像素密度比和设备像素比不一致,文字会发虚,我一般会先调用ctx.scale(dpr, dpr)做适配,后面避坑章节会细说。

5. 避坑与常见问题:触屏适配、资源加载和性能优化的五个真实踩坑记录

5.1 触屏事件坐标系偏移:点击位置和果蔬位置对不上

现象:真机上点击果蔬没反应或者点偏了,模拟器上却是正常的。

原因:canvas 的 CSS 尺寸和实际像素尺寸不一致,导致touch事件的clientX/clientY与 canvas 坐标系之间存在缩放偏差。模拟器的窗口比例和真机不一致,这个问题在真机上暴露得特别明显。解决:在注册触摸事件时,把客户端坐标乘以 canvas 宽高比换算成游戏坐标,同时处理设备像素比。

// 触摸坐标转换为游戏坐标 — 解决真机偏移问题 canvas.addEventListener('touchstart', (e) => { // 获取触摸点相对于 canvas 的坐标 const touch = e.touches[0] // 关键:除以 canvas 的缩放比例,得到游戏内坐标 const gameX = touch.clientX * (canvas.width / canvas.clientWidth) const gameY = touch.clientY * (canvas.height / canvas.clientHeight) // 命中检测 handleTap(gameX, gameY) })

逻辑说明:canvas.width是物理像素宽,canvas.clientWidth是 CSS 显示宽,两者相除得到缩放系数。如果不做这步换算,iPhone 和安卓机型会轮流翻车,而且不是固定偏移,是随屏幕宽度变化的。我自己的习惯是做一个convertToGameCoord工具函数,所有事件处理统一走它,不要各处临时换算。

5.2 图片加载失败却不报错:白屏的罪魁祸首

现象:游戏启动后画面一片空白,控制台没有明显报错。

原因:资源加载器的onerror回调只打了日志,没有做失败重试或错误显示,如果某张图加载失败(本地路径写错、文件名大小写不一致、资源文件缺失),游戏会继续启动,但绘制时拿不到图片实例,画出来就是空白。

解决:在加载器里增加失败计数,超过最大重试次数后进入错误状态,显示错误提示而不是白屏。代码层面需要区分「正在加载」和「加载完成」两个状态,主场景在资源未全部加载前不进入游戏循环。

// 资源加载失败处理 — 避免白屏直接开始 loadWithRetry(item, retryCount) { const img = new Image() img.onload = () => { this.loadedImages[item.name] = img this.checkComplete() } img.onerror = () => { // 重试次数小于 3,继续尝试 if (retryCount < 3) { setTimeout(() => { this.loadWithRetry(item, retryCount + 1) }, 200) } else { // 重试耗尽,记录失败并提示 console.error('资源多次加载失败,请检查路径:', item.src) this.failed = true } } img.src = item.src }

这里的参数retryCount是当前已重试次数,超过 3 次就放弃并标记失败。我实际项目里会在checkComplete里判断this.failed,为 true 时终止游戏循环,并在画布上绘制错误文字,把问题直接暴露出来,比黑屏好排查多了。

5.3 帧率不稳定导致果蔬「瞬移」

现象:低端安卓机上,果蔬下落不是均匀的,隔几帧会跳一大段。

原因:下落速度用「每帧固定像素」表示,帧率高就掉得快,帧率低就掉得慢。如果游戏运行在 60 帧设备上而开发时按 30 帧调的速度,真机就会快一倍;反之则慢。

解决:把移动速度统一改为「每秒像素数」,每帧位移量按speed * deltaTime / 1000计算,以帧间隔时间做伸缩。这个改造看着小,但涉及到所有果蔬的运动逻辑,不是改一个变量的事。

// 每秒速度换算为每帧位移 — 帧率无关的移动 update(fruit, deltaTime) { // deltaTime 单位为毫秒 const deltaSeconds = deltaTime / 1000 // fruit.speed 定义为每秒下落的像素数 fruit.y += fruit.speed * deltaSeconds }

逻辑说明:deltaTime是上一帧到当前帧的间隔,deltaSeconds把毫秒换成秒。假设speed = 300(每秒 300 像素),帧间隔 16ms 时单帧位移约 4.8 像素,帧间隔 33ms 时约 9.9 像素,总效果一致。这个写法是所有小游戏通用的标准做法,新手一定要尽早改过来,不然项目做大了再改,所有速度参数都要重新调。

5.4 高 DPR 屏幕画面模糊:Canvas 模糊的根源

现象:iPhone 的高清屏上果蔬边缘锯齿严重,文字发虚,安卓反而没那么明显。

原因:canvas 默认按 CSS 像素渲染,在设备像素比(DPR)大于 1 的屏幕上,物理像素比 CSS 像素多,如果没缩放,绘制内容会被拉伸,自然就糊了。

解决:动态获取window.devicePixelRatio,把 canvas 的实际尺寸扩大为 CSS 尺寸乘以 DPR,然后调用ctx.scale(dpr, dpr),保证绘制时的坐标系统还是 CSS 像素,但底层渲染精度提升了。

// 高 DPR 适配 — 让画面在真机上保持清晰 const dpr = window.devicePixelRatio || 2 // 扩大 canvas 物理像素尺寸 canvas.width = screenWidth * dpr canvas.height = screenHeight * dpr // 缩放绘制上下文,所有绘制代码不需要额外调整 ctx.scale(dpr, dpr)

参数说明:dpr在 iPhone 上通常是 2 或 3,低端安卓是 1.5 左右。ctx.scale(dpr, dpr)之后,原来写ctx.fillRect(0, 0, 100, 100)仍然按逻辑坐标 100 像素绘制,但底层对应 200 物理像素,清晰度翻倍。这里也有一个代价:canvas 像素变多,绘制性能开销变大,如果游戏物体多,需要在清晰度和帧率之间做取舍。

5.5 分页加载与分包问题:资源目录变大的隐患

现象:调试阶段一切正常,上传发布时提示包体超限,或者启动白屏。

原因:微信小游戏有主包体积限制,所有图片资源如果都塞在主包里,很容易超限。更隐蔽的是,开发工具的缓存机制可能让你误以为资源都加载成功了,真机首次启动时资源加载顺序不对,部分图片渲染不出来。

解决:把资源按功能拆分为子包,启动时只加载首屏必需资源,果蔬图片等大资源通过wx.loadSubpackage按需加载。如果不能做分包,至少要把资源文件压缩,并检查所有图片是否真的被使用——资源列表里经常有引用失效的残留文件。

// 分包加载示例 — 首屏只需要背景和加载框 wx.loadSubpackage({ name: 'stage1', success: () => { // 分包加载完成,初始化游戏逻辑 this.initGame() }, fail: (err) => { // 失败重试或降级处理 console.error('分包加载失败', err) } })

逻辑说明:name参数对应game.json里subpackages配置的根目录名。要注意分包加载是异步的,必须在success回调里再创建游戏实例,不能因为加载中就让用户盯着空白页面。这个坑在开发工具里基本测不出来,真机上才会暴露,所以发布前一定要用真机做「冷启动完整流程」测试。

6. 进阶玩法扩展:从源码到自定义玩法的几个改造思路

拿到源码能跑通只是第一步,要想做成自己的游戏,至少有三个可以动手的改造方向。

第一个方向是「关卡化」。源码里果蔬生成的难度曲线是全局一条线,没有关卡概念。改造方法:把关卡参数抽成一个配置二维数组,每个关卡单独配置生成间隔、速度系数、目标分数和可用果蔬类型。代码上只需要在Main类里加一个levelConfig字段,update里根据当前关卡读取对应参数,难度切换用关卡分数阈值触发。这是投入产出比最高的改造——小游戏玩家对关卡进度的感知比对难度曲线的感知强得多。

第二个方向是「组合玩法」。比如给果蔬增加特殊技能:炸弹分三种(普通炸、冻结全场、反向操作),收集三个同类型果蔬触发合成。源码的碰撞检测和对象池结构完全支持这种扩展,只需要给果蔬对象加一个skillType字段,在碰撞回调用switch分支处理不同效果。我建议先做冻结和反向两种,代码量小而且测试效果明显。

第三个方向是「音效与动效补全」。这份源码没有完整的音效系统,只有图像动画。补齐方法不难:用短音频文件做得分音效、爆炸音效,用wx.createInnerAudioContext创建音频实例,播放逻辑挂在分数变化和碰撞判定处。动效方面可以用帧动画系统做果蔬接住时的缩放回弹动画。

最后一个我自己的习惯:拿到任何一份源码,先把README里没写明白的参数全部摸一遍,用注释标注出自己的理解,尤其是速度、时间阈值和权重这三个方向的参数——不做笔记直接跑,一个月后回来改配置时,你已经忘了当时800和0.5各自代表什么了。从那以后我每次拆项目都强制自己走一遍这个流程,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询