简介:这是一份微信小游戏版本的经典贪吃蛇源码,面向微信小游戏初学者与希望快速上手完整项目流程的开发者。使用微信开发者工具导入即可编译运行,核心代码涵盖游戏循环、蛇身移动、食物生成与碰撞判断等逻辑,适合作为入门练手或课程设计的参考蓝本。压缩包共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 两条线都没有持续尖峰,手感就算调到位了。
本文还有配套的精品资源,点击获取