微信小游戏贪吃蛇源码解析:从主循环到离屏Canvas优化
简介这是一份微信小游戏版本的经典贪吃蛇源码面向微信小游戏初学者与希望快速上手完整项目流程的开发者。使用微信开发者工具导入即可编译运行核心代码涵盖游戏循环、蛇身移动、食物生成与碰撞判断等逻辑适合作为入门练手或课程设计的参考蓝本。压缩包共12个文件以8个js脚本为主负责游戏逻辑3个json文件用于项目与页面配置另含1个md说明文档包体仅18KB整体结构轻量清晰。目前已有562人学习浏览说明其对新手颇具参考价值。通过阅读源码读者能拿到完整可运行的贪吃蛇小游戏理解小游戏项目的目录组织、事件绑定与渲染处理方式在动手调整速度、地图尺寸或计分规则时快速建立微信小游戏开发的基本框架与排错思路。1. 贪吃蛇微信小游戏源码先别急着看代码这个标题带来的项目源码价值不在“贪吃蛇”本身而在于它把微信小游戏最基础的运行链路完整走了一遍。同样一个贪吃蛇网页版可以用 DOM 加 setInterval 轻松实现但微信小游戏环境里没有 window、没有 document连定时器都不能想当然地直接用运行链路和渲染方式完全是另一套规则。源码通常就是一套运行在 Canvas 2D 上的最小样板入口配置、主循环、触摸监听、碰撞逻辑、局部渲染。适合正在做微信小游戏入门的人也适合需要一个原生小游戏工程做底座、再往上改造的人。读这份代码的顺序建议按运行顺序来先看配置再看主循环最后才看蛇怎么走。2. 项目结构拆解微信小游戏和网页版贪吃蛇的分水岭微信小游戏的工程形态和网页项目差别很大。网页有 HTML、CSS、JavaScript 三层分离小游戏把这三层压缩成了 game.js 加一个全局对象 GameGlobal。没有 DOM没有 BOM主要靠 wx 命名空间下的 API 与运行环境交互。拿到项目源码先不急着翻业务代码要按运行顺序看文件project.config.json 是开发者工具的工程配置game.json 声明小游戏运行参数game.js 是唯一入口之后的模块都是通过 require 或 import 挂到 GameGlobal 上的。2.1 game.json 与 game.js入口文件决定了源码的启动顺序game.json 是小游戏的行为声明文件在开发者工具里修改后会同步生效真机上则随代码包一起上传。一个针对竖屏贪吃蛇的典型配置如下{ deviceOrientation: portrait, showStatusBar: false, networkTimeout: { request: 5000 } }deviceOrientation锁定竖屏贪吃蛇的滑动操作在竖屏下最顺手也避免玩家翻转手机时坐标系突变。showStatusBar设为 false 是为了让游戏区铺满屏幕真机上状态栏仍然存在只是画布绘制区域不受它影响。networkTimeout是网络请求超时时间单位毫秒这里虽然贪吃蛇大概率不用网络但保留这个字段是源码工程的标准写法。game.js 作为入口负责初始化画布、注册事件、启动循环。大多数源码会把它写成这样const canvas wx.createCanvas() const ctx canvas.getContext(2d) function tick() { update() render() canvas.requestAnimationFrame(tick) } canvas.requestAnimationFrame(tick)wx.createCanvas()首次调用返回的是上屏画布之后再次调用得到的都是离屏画布很多源码在多个模块里分别调它结果画面画不出来问题就出在这。tick是主循环体update 处理逻辑render 负责绘制最后通过canvas.requestAnimationFrame(tick)请求下一帧。2.2 画布尺寸与像素比高分屏下蛇会发虚源码里常见的第二个坑是画布大小。小游戏的canvas.width和canvas.height默认与屏幕逻辑分辨率一致但在高像素比设备上不乘pixelRatio的话线条和方块的边缘会明显锯齿。适配写法是const info wx.getWindowInfo() const dpr info.pixelRatio || 2 canvas.width info.screenWidth * dpr canvas.height info.screenHeight * dpr ctx.scale(dpr, dpr)info.screenWidth是 CSS 像素意义上的宽乘以dpr得到物理像素宽画布按物理像素渲染后又用ctx.scale把坐标系恢复到逻辑尺寸。后续所有绘制代码可以继续用screenWidth做计算不需要在业务代码里到处乘系数。贪吃蛇的格子尺寸建议用screenWidth / cols计算而非固定 32 像素这样不同屏幕下格子能均匀铺满。2.3 主循环为什么不用 setInterval小游戏环境里 setInterval 并非不可用但用来做游戏主循环有三个明显问题真机切后台时定时器可能被系统挂起回到前台时又可能连续补偿执行定时器回调与屏幕刷新不同步快帧的时候画面闪烁开发者工具里调试还会出现定时器乱跳的情况。主循环应该由渲染管线驱动。方案驱动方式切后台表现适用场景setInterval系统定时器挂起或补偿执行倒计时、网络轮询canvas.requestAnimationFrame屏幕刷新回调自动暂停回前台继续游戏主循环、动画wx.setPreferredFramesPerSecond屏幕刷新参数跟随系统主动限制帧率canvas.requestAnimationFrame在切后台时会自动停止回调回前台后自动恢复不需要手动管理暂停。想要限制帧率时用wx.setPreferredFramesPerSecond({ fps: 30 })参数只支持 30 或 60这个 API 适合低配机型保续航但注意不要在循环体内重复调用。3. 贪吃蛇核心逻辑把“蛇”用数组存起来搜过相关代码的人会发现网上大量版本用 C 语言链表存蛇身再用方向键控制。微信小游戏里没有链表这个原生结构也没有键盘事件最贴近源码实现的做法是用 JavaScript 数组加方向向量。数据结构选对了移动、吃食物、撞墙三段逻辑都能在几十行内写完。3.1 snake 数组与方向向量为什么用队列建模蛇的移动天然符合队列特征头部插入一格尾部丢弃一格长度不变吃到食物时只插不丢长度加一。JavaScript 数组的unshift和pop正好对应这两个操作。坐标用对象{ x, y }表示x 是列号y 是行号不直接存像素值的原因是碰撞判定在格子坐标系里做最干净。const grid { cols: 10, rows: 15, cell: 32 } const snake [ { x: 4, y: 7 }, { x: 3, y: 7 }, { x: 2, y: 7 } ] let direction { x: 1, y: 0 }snake[0]是蛇头数组末尾是蛇尾。direction表示当前移动方向x 为 1 表示向右y 为 -1 表示向上。之所以不把方向直接合并进蛇头对象是因为方向在每一帧开始时会被替换成玩家的触摸输入单独存变量便于做反向拦截也便于后续给 AI 或自动演示模式替换逻辑。3.2 移动、吃到食物与死亡判定一个 tick 内做完的三件事主循环的update里做的事情是有顺序的顺序错了会出诡异的 bug。正确顺序是先算下一步头部位置再判撞墙和撞自己然后决定是否去掉尾巴。function step() { const head snake[0] const next { x: head.x direction.x, y: head.y direction.y } const hitWall next.x 0 || next.x grid.cols || next.y 0 || next.y grid.rows const hitSelf snake.some(seg seg.x next.x seg.y next.y) if (hitWall || hitSelf) { gameOver() return } snake.unshift(next) if (next.x food.x next.y food.y) { score 1 food placeFood() } else { snake.pop() } }撞墙判定用格子坐标做边界比较避免在像素坐标里判断“蛇头是否超出画布边缘”。撞自己判定用some遍历蛇身注意这个检查必须在unshift之前执行否则蛇头永远会跟自己重叠任何移动都判为死亡。unshift插入新头之后如果没吃到食物就用pop移除尾巴数组长度保持不变吃到食物则不 pop长度自然加一。3.3 食物随机生成与分数速度联动placeFood不能简单随机到一个格子因为随机坐标可能落在蛇身上导致游戏刚开始就卡死。最小做法是循环随机采样直到选中不在蛇身上的格子function placeFood() { let point do { point { x: Math.floor(Math.random() * grid.cols), y: Math.floor(Math.random() * grid.rows) } } while (snake.some(seg seg.x point.x seg.y point.y)) return point }Math.random()的范围是 [0, 1)乘以 cols 后向下取整得到的格子范围在 [0, cols - 1]正好避开墙边界。do while保证至少执行一次判断蛇越长随机到空白格子的概率越低最坏情况下可能循环很多次但贪吃蛇的棋盘最多几百格性能影响可以忽略。速度和分数联动是手感的关键。慢速版用固定间隔计步把主循环里的ticks计数和移动间隔分开let ticks 0 let moveInterval 8 function update() { ticks 1 if (ticks % moveInterval ! 0) return step() }每积攒 8 帧移动一次蛇每吃 5 个食物让moveInterval减 1最低不低于 3。贪吃蛇的难度上限由网格大小决定格子太少速度再快也没意义多数原生源码会按屏幕宽度算出 cols再按比例算出 rows保持方格接近正方形。4. 从源码到可玩触摸方向、渲染与微信能力接入把蛇的逻辑写完只完成了一半另一半是让玩家能操作它。微信小游戏没有键盘事件方向输入来自触摸屏如何把 “按下的点” 换算成 “上/下/左/右” 是一个固定套路。渲染部分则要处理清屏和重绘的关系以及调用微信特有的震动、数据缓存接口来提升手感。4.1 wx.onTouchStart 的方向换算与 180 度回绕触摸事件wx.onTouchStart每次触发会传入包含touches数组的事件对象每个 touch 带有clientX和clientY表示触点相对屏幕左上角的坐标。方向换算只需要判断按下的点和画布中心点的相对位置let pendingDirection null wx.onTouchStart((e) { const touch e.touches[0] const dx touch.clientX - canvas.width / 2 const dy touch.clientY - canvas.height / 2 if (Math.abs(dx) Math.abs(dy)) { pendingDirection { x: dx 0 ? 1 : -1, y: 0 } } else { pendingDirection { x: 0, y: dy 0 ? 1 : -1 } } })Math.abs(dx) Math.abs(dy)比较的是水平偏移和垂直偏移的绝对值谁大就朝哪个方向走避免斜向按压时同时触发两个方向。clientX除以 2 得到屏幕中心点的近似坐标在实际设备上画布不一定占满全屏更精确的写法是减去canvas.getBoundingClientRect()的偏移但贪吃蛇这种全屏游戏直接除以 2 即可。方向暂存到pendingDirection而不是直接赋值给direction这是为了防止一帧内多次触摸导致蛇头反向。应用方向的代码放在step开头function applyDirection() { if (!pendingDirection) return if (direction.x pendingDirection.x 0 direction.y pendingDirection.y 0) return direction pendingDirection pendingDirection null }180 度回绕的判断逻辑很直接原方向是{ x: 1, y: 0 }新方向如果是{ x: -1, y: 0 }两个 x 相加为 0说明玩家想让蛇掉头直接忽略。注意这里用的是加法判断而不是乘法判断因为 0 乘任何数都是 0加法才能准确表达“互为相反向量”的关系。4.2 渲染侧的三件事清屏、按格绘制、避免重影每帧 render 的第一件事是ctx.clearRect清理整个画布。不清理的后果是上一帧的蛇身和食物残留形成重影。清除范围写全屏才能保证旧画面完全消失function render() { ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.fillStyle #1a1a1a ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.fillStyle #e74c3c ctx.fillRect(food.x * grid.cell, food.y * grid.cell, grid.cell - 1, grid.cell - 1) ctx.fillStyle #2ecc71 snake.forEach((seg, i) { ctx.fillRect(seg.x * grid.cell, seg.y * grid.cell, grid.cell - 1, grid.cell - 1) }) }grid.cell - 1作为宽高是为了让相邻格子之间留出 1 像素缝隙在深色背景上形成网格线感省掉绘制格线的额外代码。fillStyle的切换是有代价的每切换一次绘图状态在低端机上都对应一次内部状态重建所以尽量把同颜色对象集中画这也是蛇身统一使用一个 fillStyle、食物单独再设一个的原因。4.3 接上 wx.vibrateShort 与保存最高分微信小游戏源码和普通网页版的核心差异在于能调用平台能力。吃食物和撞墙时加一个轻震动是把游戏从“能玩”变到“好玩”的常用手段function playEatFeedback() { if (wx.canIUse(vibrateShort.object.type)) { wx.vibrateShort({ type: light }) } else { wx.vibrateShort() } }wx.vibrateShort的type参数在基础库 2.13.0 开始支持可选heavy、medium、light旧版本直接调用不带参数。用wx.canIUse做判断比查版本号再比较要省事。这个 API 不需要用户授权但真机上调用间隔不能太频繁快速连吃多个食物时每一帧都震马达反应不过来。最高分的保存则用同步存储接口const best wx.getStorageSync(snake_best) || 0 if (score best) { wx.setStorageSync(snake_best, score) }wx.getStorageSync返回空字符串时用|| 0兜底避免后续比较时把空字符串和数字混在一起。setStorageSync是同步接口数据量小、调用不频繁时用这个最直白数据量大并且要在多端同步时再考虑wx.setCloudStorage这类方案但贪吃蛇这种单机游戏不需要。5. 真机预览与离屏 Canvas 渲染优化跑懂源码后再改装的三个技巧源码能跑通只是第一步上真机经常发现开发工具里流畅、真机上卡顿的情况。两个最常见的优化点静态背景重复绘制、以及像素比适配不正确导致的分辨率放大。离屏 Canvas 是微信小游戏提供的官方方案把只画一次的内容提前渲染好每帧直接drawImage贴上去。5.1 用离屏 Canvas 缓存静态网格背景在首次调用wx.createCanvas()之后再调用一次拿到的就是离屏画布。它不会上屏只作为位图资源使用const offscreen wx.createCanvas() offscreen.width canvas.width offscreen.height canvas.height const octx offscreen.getContext(2d) octx.fillStyle #1a1a1a octx.fillRect(0, 0, offscreen.width, offscreen.height) octx.strokeStyle #333333 for (let i 0; i grid.cols; i) { octx.beginPath() octx.moveTo(i * grid.cell, 0) octx.lineTo(i * grid.cell, canvas.height) octx.stroke() } function render() { ctx.drawImage(offscreen, 0, 0) // 之后照常绘制食物和蛇身 }主循环里原来用fillRect清屏加画网格的代码变成一次drawImage省掉十几次绘图状态切换。注意离屏 Canvas 的尺寸要和上屏画布一致否则drawImage会触发缩放导致网格线变糊。5.2 真机掉帧时先看这三处页面掉帧优先检查画布是否在每次触摸时被销毁重建。部分源码在wx.onTouchStart里重新获取canvas的 context这是开销最大的操作context 只需要在初始化时拿一次。其次检查clearRect和背景fillRect是否覆盖全屏覆盖率不足时上一帧残留会干扰视觉上的流畅感。最后看主循环里是否做了字符串拼接或对象字面量创建snake.forEach回调里临时创建坐标对象同样会产生垃圾回收压力改用seg.x * grid.cell直接参与计算比构造对象再取属性更省。把蛇身画法从每节一个fillRect改成用Path2D合并同类项const path new Path2D() snake.forEach(seg { path.rect(seg.x * grid.cell, seg.y * grid.cell, grid.cell - 1, grid.cell - 1) }) ctx.fillStyle #2ecc71 ctx.fill(path)一次fill(path)代替多次fillRect绘图调用数从蛇身长度降到常量 1这是小游戏 Canvas 2D 优化里性价比最高的改动。配合离屏背景、像素比适配和requestAnimationFrame驱动这份贪吃蛇源码在百元机上也应该能稳定跑到 60 帧真机预览时打开开发者工具的性能面板确认 Script 和 Render 两条线都没有持续尖峰手感就算调到位了。本文还有配套的精品资源点击获取