简介:这款微信小游戏贪吃蛇项目源码,适合微信小游戏初学者入门参考。资源以原生小游戏方式实现经典贪吃蛇玩法,使用微信开发者工具导入小游戏模板即可直接编译运行,便于对照学习游戏循环、触摸事件、画布绘制等核心逻辑。压缩包共12个文件,以8个JS脚本为主,承担游戏主逻辑、工具函数与模块拆分;3个JSON文件分别用于项目配置与游戏配置;1个Markdown文档提供简要说明,整体仅18KB,结构清晰易读。已有562人学习/下载。源码虽小但五脏俱全,从界面渲染到手势控制均有对应实现,可作为第一个微信小游戏练手项目,也可在此基础上扩展关卡、计分与音效。无论想了解Canvas绘图、触摸响应,还是游戏状态机,这份源码都能提供直观示例,帮助快速迈入小游戏开发门槛。
1. 拿到贪吃蛇微信小游戏源码,先别急着跑
一份贪吃蛇微信小游戏的项目源码,打开开发者工具能跑起来只是第一关。真正要把它改造成能交付、能上架、能适配不同屏幕比例的东西,得先看清微信小游戏的运行模型:渲染不走 DOM,直接操作 Canvas;输入不是键盘,而是触摸事件;主循环没有浏览器里的 setInterval 兜底,requestAnimationFrame、帧间隔、碰撞检测全部要自己拼。这份源码的价值不在「能用」,而在它把微信小游戏的最小骨架摆在了桌面上——入口文件、game.json 配置、全局画布、游戏循环、触摸响应五件事,缺一件后面都要返工。适合想弄懂小游戏底层结构、又不想从空工程搭起的开发者。
2. 用微信小游戏 API 搭出贪吃蛇的网格、移动与触控输入
为什么网页版贪吃蛇源码不能直接搬进微信小游戏?因为运行环境没有 document、window,所有原生能力都收敛到 wx.* 系列 API。画面输出靠 wx.createCanvas(),第一次调用拿到的是上屏画布,后续调用返回离屏画布;输入靠 wx.onTouchStart / onTouchEnd,没有 keydown。搞清楚这两点,整个源码的骨架就清楚了。
2.1 先对齐 game.json 与入口文件
每个微信小游戏工程必须有 game.json 和 game.js。game.json 声明设备方向、渲染模式与网络超时;game.js 是入口,初始化逻辑直接写顶层,没有 DOMContentLoaded 这类生命周期。
{ "deviceOrientation": "portrait", "showStatusBar": false, "networkTimeout": { "request": 10000 } }deviceOrientation 设为 portrait 强制竖屏,贪吃蛇单手操作比横屏顺手;showStatusBar 关闭系统状态栏,避免游戏顶部多出一条非绘制区域;networkTimeout 只影响 wx.request,本地玩法用不到,但显式声明能避免真机网络慢时默认超时太久。整套源码的启动路径就是:微信加载 game.json 拿到配置,再执行 game.js。
2.2 创建全局画布并适配逻辑分辨率
屏幕尺寸要用 wx.getWindowInfo() 取,返回的是逻辑像素。物理像素适配是新手最容易漏掉的一步,漏了之后同一套代码在 iPhone 和 Android 上清晰度差别很明显。
const { windowWidth, windowHeight, pixelRatio } = wx.getWindowInfo(); const canvas = wx.createCanvas(); canvas.width = windowWidth * pixelRatio; canvas.height = windowHeight * pixelRatio; const ctx = canvas.getContext('2d'); ctx.scale(pixelRatio, pixelRatio);先给画布分配物理像素尺寸,再通过 ctx.scale 让后续所有绘制命令按逻辑坐标走。如果不做这一步,网格线的位置会偏移半个物理像素,正方形格子边缘出现锯齿。
| 概念 | 来源 | 作用 |
|---|---|---|
| windowWidth | wx.getWindowInfo() | 逻辑宽度,UI 布局与坐标计算基准 |
| pixelRatio | 同上 | 物理像素与逻辑像素比值,常见为 2 或 3 |
| canvas.width | 手写赋值 | 物理像素宽度,决定绘制精度 |
| ctx.scale | 手写调用 | 让后续 draw 命令映射到逻辑坐标系 |
后续所有绘制坐标都以 windowWidth、windowHeight 为边界,不要在绘制函数里再乘一次 pixelRatio。
2.3 蛇身数据结构与自撞检测
蛇身最基本的存法是数组:每移动一步,把新头插入头部,没吃到食物就弹出尾部。但数组头部插入是 O(n) 操作,贪吃蛇格子少时无所谓,地图放大到 40×40 以上、帧率拉到 60fps 时就能感觉到偶发卡顿。我一般用固定长度的类型化数组加头索引来存蛇身,把运行时内存分配降到零。
const mapSize = 22; const capacity = mapSize * mapSize; const segX = new Int32Array(capacity); const segY = new Int32Array(capacity); let headIndex = 0; let len = 4; let dir = { x: 1, y: 0 }; let pendingDir = null; function setDirection(name) { const map = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }; const d = map[name]; if (!d) return; if (d.x + dir.x === 0 && d.y + dir.y === 0) return; pendingDir = d; } function moveSnake() { if (pendingDir) { dir = pendingDir; pendingDir = null; } const newX = segX[headIndex] + dir.x; const newY = segY[headIndex] + dir.y; if (newX < 0 || newX >= mapSize || newY < 0 || newY >= mapSize) { gameOver(); return; } for (let i = 0; i < len; i++) { const idx = (headIndex - i + capacity) % capacity; if (segX[idx] === newX && segY[idx] === newY) { gameOver(); return; } } headIndex = (headIndex + 1) % capacity; segX[headIndex] = newX; segY[headIndex] = newY; if (isFood(newX, newY)) { len++; spawnFood(); } draw(); }逻辑说明:segX、segY 一次性分配 capacity 个格子,移动时头索引顺时针推进,旧尾巴位置自然被新头覆盖。自撞检测用(headIndex - i + capacity) % capacity从当前头向前回绕 len 段,只检查存活部分,避免把已被覆盖的旧数据也算进去。setDirection 里过滤 180 度反向是必须的,否则连续快速操作会让蛇头直接穿进自己身体。
绘制函数保持简单:先 fillRect 清屏,再画蛇身和食物,绘制也要靠 headIndex 回绕读取。
function draw() { ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, windowWidth, windowHeight); ctx.fillStyle = '#e94560'; for (let i = 0; i < len; i++) { const idx = (headIndex - i + capacity) % capacity; ctx.fillRect(segX[idx] * CELL_SIZE, segY[idx] * CELL_SIZE, CELL_SIZE - 1, CELL_SIZE - 1); } ctx.fillStyle = '#f5c542'; ctx.fillRect(food.x * CELL_SIZE, food.y * CELL_SIZE, CELL_SIZE, CELL_SIZE); }每个格子画成 CELL_SIZE 减 1 像素,留出 1px 间隙,视觉上比紧贴的方块更容易区分边界。CELL_SIZE、food 等变量在初始化函数里赋值,全局只维护一套。
2.4 触摸输入:滑动与虚拟方向键两条路
微信小游戏没有键盘,输入要么靠整屏滑动,要么靠屏幕上的虚拟方向键。滑动适合追求手感的老玩家,虚拟按键适合教学关。源码里最稳的做法是一套输入适配器同时接两种来源,统一走 setDirection。
let startTouch = null; wx.onTouchStart((e) => { startTouch = e.touches[0]; }); wx.onTouchEnd((e) => { if (!startTouch) return; const t = e.changedTouches[0]; const dx = t.clientX - startTouch.clientX; const dy = t.clientY - startTouch.clientY; if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return; if (Math.abs(dx) > Math.abs(dy)) { setDirection(dx > 0 ? 'right' : 'left'); } else { setDirection(dy > 0 ? 'down' : 'up'); } startTouch = null; });onTouchStart 记录起点,onTouchEnd 计算位移。20 像素阈值用来区分点击与滑动,防止玩家手指轻微移动就误换方向。视觉方向键则是把四个按键的区域坐标写死,在 onTouchStart 里命中检测后调 setDirection,两者共用同一套方向过滤逻辑。
提示:多指触控时 e.touches[0] 只取第一个触点,若玩家手指换了一根,startTouch 可能失效。需要在 onTouchStart 里记录 identifier,onTouchEnd 按 identifier 匹配,避免滑动中断后方向错乱。
3. 游戏循环、固定时间步进与真机性能参数调试
贪吃蛇的核心循环只有一句话:每过固定间隔蛇移动一格,每帧画面重绘。但三种写法的效果差别不小。
- setInterval(fn, ms):写起来最快,但定时器受运行环境调度影响,低电量模式下会被合并成 1000ms 触发一次,蛇直接瞬移。
- requestAnimationFrame(fn):微信小游戏支持,跟随屏幕刷新率,逻辑却和设备帧率绑死,60Hz 和 120Hz 设备上蛇速不一样。
- 逻辑帧与渲染帧分离:移动逻辑固定间隔,渲染跟随刷新率。这是 2D 游戏里最常见的做法,也是这套源码里推荐的主循环结构。
3.1 用 requestAnimationFrame 搭出主循环
const TICK_MS = 120; let lastTick = 0; let paused = false; function frame(timestamp) { if (!paused && timestamp - lastTick >= TICK_MS) { moveSnake(); lastTick = timestamp; } draw(); requestAnimationFrame(frame); } requestAnimationFrame(frame);timestamp 由系统传入,单位毫秒。移动和绘制分离是关键改动:moveSnake 只更新数据,draw 只负责画,两者不互相调用。TICK_MS 决定速度,120 表示每 120ms 走一格;要提供三档难度,就把它做成变量,在设置面板里改。暂停不能取消 requestAnimationFrame,否则再恢复时会有一帧空档,正确做法是用 paused 标志跳过移动但继续渲染,挂起画面里的暂停按钮还能保持闪烁。
3.2 从 setInterval 迁移到 RAF 的坑
从网页版贪吃蛇源码迁移时最容易踩的坑是:canvas 绘制代码原样保留,但把 setTimeout 换成 setInterval 后真机上速度忽快忽慢。原因是安卓部分机型的省电策略会把 timer 聚合到 1000ms 一次,然后一次性补跑多次逻辑。requestAnimationFrame 的 timestamp 是单调递增的,不受补帧影响;在开发者工具里不勾选「帧率模拟」,就能复现这个差异。
3.3 帧率与绘制开销的排查参数
微信小游戏没有网页端那种打开即用的 Performance 面板,对游戏循环的逐帧追踪要自己埋点。wx.getPerformance() 是最直接的入口。
const perf = wx.getPerformance(); const observer = perf.createObserver(() => { const entry = perf.getEntriesByName('frame')[0]; const cost = entry ? Math.round(entry.duration) : 0; if (cost > 33) { console.warn('frame cost too high:', cost, 'ms'); } });getEntriesByName('frame') 返回最近一帧渲染耗时。超过 33ms 说明这一帧已经跌破 30FPS,优先检查绘制函数是不是逐格 fillRect 导致状态切换过多,或者是 fillText 每帧重复绘制静态文本造成不必要的重排。
| 现象 | 常见原因 | 排查手段 |
|---|---|---|
| 帧率掉到 20FPS | 每格单独 fillRect,状态频繁切换 | 先整体填充背景,再只画蛇和食物 |
| 偶发卡顿 | 每帧 new 临时对象触发 GC | 用类型化数组,消息体复用对象池 |
| 真机发烫 | 后台仍持续渲染 | onHide 里取消 RAF,onShow 时重新启动 |
3.4 真机预览与调试入口
工具右上角「预览」生成二维码后,真机上默认没有 vConsole,报错看不见。要打开调试面板,在 game.js 顶层调用:
wx.setEnableDebug({ enableDebug: true });这个接口只能在开发者工具开启调试模式时生效,正式发布包默认关闭。调试面板打开后能看到每帧耗时、console 输出与网络请求。上线前记得在打包脚本里剔除该调用,否则正式版会请求失败并打印一条无害但刺眼的报错。
提示:调试模式下 setEnableDebug 在部分基础库版本中会因重复调用报错,用 try/catch 包住,避免初始化流程被打断。
4. 微信小游戏源码交付:竖屏适配、分包与著作权检查
「项目源码」交付意味着别人要能接得住:代码能跑只是起点,竖屏设置、首包体积、资质材料缺一不可。这三个问题在贪吃蛇这种小体量项目里尤其典型。
4.1 横竖屏参数与安全区适配
从网页版改来的源码默认横屏,贪吃蛇却更适合竖屏。game.json 里 deviceOrientation 设为 portrait 解决的是屏幕旋转,设计分辨率还要跟着改。
const MARGIN_TOP = 80; const cols = Math.floor((windowWidth - 2 * PADDING) / CELL_SIZE); const rows = Math.floor((windowHeight - MARGIN_TOP - 2 * PADDING) / CELL_SIZE);CELL_SIZE 建议取 16 的倍数,375px 宽的常见逻辑分辨率下能整除;设成 18 这种数会让最后一列蛇头画到边界外。PADDING 不能写死,刘海屏、挖孔屏的安全区各不相同。wx.getWindowInfo() 拿不到安全区,要读 wx.getSystemInfoSync() 的 safeArea 字段,把 PADDING 替换成 safeArea 的 top 与 bottom 偏移。Web 端那套 safe-area-inset 的 CSS 环境变量在这里完全无效。
4.2 首包体积限制与资源降级
微信小游戏主包限额 4MB、总包 20MB,具体数值以微信公众平台最新公告为准。贪吃蛇本体不大,但加入高清背景图、音效和字体后很容易逼近主包上限。素材超限时,最常见的做法是把新手引导、设置页拆到分包。
{ "subpackages": [ { "name": "tutorial", "root": "sub/tutorial/", "independent": false } ] }wx.loadSubpackage({ name: 'tutorial', success() { /* 进入新手引导 */ }, fail(err) { /* 降级为简单文字说明 */ } });分包里的代码无法被主包直接同步 require,加载是异步的,必须在 success 回调里再切换场景,提前切会白屏。贪吃蛇这种轻量项目,我更倾向不做分包:背景色用代码绘制、图片压成 webp、音效用短 ogg,省下来的体积比分包调度更可控。
4.3 交付前的著作权与合规检查
「项目源码」交付还有一个隐形坑:代码能跑,接手方却卡在提审。微信小游戏上架必须选择游戏类目,通常需要软著或版号信息,具体以微信公众平台当前要求为准。给客户做定制交付时,交付清单里应包含源码版权归属的书面说明,并明确建议启动软著登记的时间点,而不是等开发完才想起来。
技术侧能提前做的,是把 game.json 的类目信息与性能要求对齐,提审前用工具「上传」功能生成体验版,确认后台日志无异常报错。
5. 进阶技巧:用操作序列给贪吃蛇做一局回放
给贪吃蛇加分回放功能,顺便验证碰撞逻辑,是我推荐这份源码改造时优先做的一个小功能。回放不复杂,只记录两样东西:时间戳和方向。其他一切状态——蛇长、食物位置、得分——都由游戏逻辑在回放时重算。
5.1 记录操作序列
const replayOps = []; let replayStart = 0; function onDirection(d) { replayOps.push({ t: Date.now() - replayStart, dir: d }); } function startRecord() { replayOps.length = 0; replayStart = Date.now(); }5.2 回放只跑逻辑,跳过渲染
function replayFrame(timestamp) { const cursor = timestamp - replayStart; while (idx < replayOps.length && replayOps[idx].t <= cursor) { moveSnake(replayOps[idx].dir); idx++; } draw(); requestAnimationFrame(replayFrame); }回放时把 setDirection 换成直接改写 dir 的 moveSnake 调用,因为回放不需要再走触摸输入和 180 度过滤,操作序列里存的本来就是已经过滤后的合法方向。
5.3 用回放做的两个验证
第一,验证边界碰撞是否可复现。玩家反馈「明明没撞墙却死了」,回放一遍就能发现:高速连按时,上一帧的方向指令还没被移动消耗,下一帧又补了一条反向指令,蛇头同一回合被翻转 180 度直接撞到自己。setDirection 里过滤反方向的代码不能放在 pendingDir 赋值之后,必须放在消费端上一行,让两条连续指令之间只有一条生效。回放数据会直接暴露这种时序 bug。
第二,验证食物生成的公平性。有些源码生成食物时只在当前空格子里随机,看似公平,但重放十局就会发现食物频繁出现在蛇头下一步会经过的路径上。回放逻辑与正常逻辑共用 spawnFood,数据和得分完全一致,任何与正常局不一致的地方都是 bug。
回放数据还可以存到 wx.setStorageSync 或上传后端,用于在线排行榜的作弊检测:服务器把玩家分数与操作序列回放一遍,对比最终蛇长是否一致,这是成本最低的防注入手段。想让回放更贴近实战,可以加一条只在回放模式生效的快进键:按住时暂停 draw 只跑 moveSnake,几百毫秒就能审完一整局。
本文还有配套的精品资源,点击获取