ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

微信小游戏开发实战:Canvas渲染、性能优化与源码详解

微信小游戏开发实战:Canvas渲染、性能优化与源码详解 做小程序开发这些年被问到最多的一类问题就是“小程序能不能做游戏源码能不能分享一下”能而且小程序生态里一直有专门的小游戏赛道。但很多刚接触这块的开发者容易踩一个误区——以为小游戏就是把普通页面换个皮肤实际上小游戏的渲染机制、代码结构、性能优化思路跟常规小程序页面完全是两套逻辑。这篇文章我就从自己实际做过的小游戏项目出发把源码、设计思路、实操流程和避坑经验一次性讲清楚代码都是可以直接拿去跑的适合刚学完小程序基础想练手的开发者也适合准备接小游戏外包或者自己做休闲游戏变现的朋友参考。1. 小游戏项目的整体设计思路与选型1.1 小程序小游戏和普通页面的本质差异很多第一次接触小游戏开发的同事会问能不能直接用 WXML 写界面、用按钮和 View 拼一个游戏出来技术上可以但性能会非常难看而且做出来更像网页 Demo不算真正意义上的小游戏。区别要从运行环境说起。普通小程序页面走的是 WebView 渲染由 WXML 描述界面结构、WXSS 控制样式事件通过组件系统分发这套机制的好处是开发快、组件丰富坏处是频繁的 data 更新会触发整棵节点树的 diff 和重绘游戏里每帧都在变化的元素一多页面立刻开始掉帧。小游戏类的项目走的是另一条路——基于 Canvas 全屏渲染配合游戏主循环requestAnimationFrame来驱动画面更新。画面里每个角色、道具、记分牌都是绘制在画布上的像素不走 WXML 的节点树所以性能上限高得多。代价是常规的button、input、scroll-view这些组件在小游戏环境里都不能直接用交互、动画、UI 全都得靠 Canvas 手动绘制和命中检测实现。另外要提醒一句微信生态里的“小游戏”是一个单独类目和“小程序”不完全是一回事。注册时小游戏类目对主体资质有要求个人主体通常注册不了正式的小游戏类目但这不代表个人开发者不能玩——完全可以在普通小程序页面里用 Canvas 做游戏化功能或者先拿个人账号练手代码逻辑是通用的等真正要上线再换企业主体。1.2 技术选型原生开发还是游戏引擎做小游戏绕不开一个选型问题用原生小程序语法还是接 Cocos、Laya、Unity 这类游戏引擎我自己的建议是休闲类、玩法单一的轻量小游戏优先用原生。原因很直接原生方案包体小、启动快、不依赖引擎的适配层不需要额外学习引擎的组件体系和发布链路遇到问题网上能查到的资料也更多。比如猜数字、打地鼠、2048、翻牌记忆、简易跑酷这类游戏纯原生 JavaScript 加 Canvas 完全够用跑起来非常流畅。那什么时候上引擎当游戏逻辑复杂到一定程度比如需要物理碰撞、粒子特效、骨骼动画、多场景管理原生代码会迅速膨胀到难以维护。这时候接 Cocos Creator 这类专门面向微信小游戏做的引擎会舒服很多渲染、资源管理、音频播放这些底层脏活累活引擎都替你扛了。Unity 也可以打包到微信小游戏但生成的包体偏大首屏加载时间长对玩家体验不友好性能要求高的小型休闲游戏不太推荐。选型可以从三个指标倒推包体大小限制主包加分包不让超过一定大小超了就得用代码分包、帧率要求60 帧还是 30 帧可接受、玩法复杂度界面数量、碰撞逻辑、特效量。这三个指标评估完选原生还是引擎基本就清楚了。1.3 项目目录结构与源码组织原生小游戏项目的目录结构通常长这样project/ ├── game.js // 小游戏入口文件 ├── game.json // 小游戏配置横竖屏、分包等 ├── project.config.json // 开发者工具项目配置 ├── images/ // 图片素材 ├── audio/ // 音频素材 ├── js/ │ ├── main.js // 初始化场景、启动主循环 │ ├── databus.js // 全局数据管理 │ ├── player.js // 玩家对象 │ ├── enemy.js // 敌人或障碍物对象 │ ├── score.js // 计分模块 │ └── utils.js // 工具函数 ├── libs/ │ └── weapp-adapter.js // 适配层 └── style/ └── canvas.css // 极少用一般可忽略这个结构参考了官方示例项目的组织方式核心思想是“按对象拆模块”。每个游戏对象独立成一个 JS 文件内部自己维护状态和绘制逻辑主循环统一驱动 update 和 render。这样拆的好处是——游戏要加新角色只需要新增一个对象文件在 databus 里注册一下主循环不用大改。如果你是在普通小程序页面里做游戏化功能目录结构就更简单按页面维度组织即可类似pages/ ├── guess/ │ ├── index.wxml │ ├── index.wxss │ ├── index.js │ └── index.json └── whack/ ├── index.wxml ├── index.wxss ├── index.js └── index.json下面要分享的三个源码案例前两个用的是页面内 Canvas 方案第三个更接近纯逻辑层实现大家按照自己手头的项目类型对号入座就行。2. 三个可以直接跑的小游戏源码拆解2.1 猜数字小游戏状态管理与交互逻辑先分享一个最基础、最适合练手的猜数字。玩法很简单系统随机生成一个 1 到 100 的整数玩家输入数字系统提示“大了”或“小了”直到猜中为止。这个游戏用普通小程序页面加 WXML 就能轻松实现核心代码在 JS 逻辑层。页面结构大致是这样view classcontainer view classtitle猜数字游戏/view view classrange范围1 - 100/view view classresult{{message}}/view view classattempts已猜次数{{count}}/view input classinput typenumber bindinputonInput placeholder输入你的猜测 / button classbtn bindtaponGuess猜/button button classbtn reset bindtapinitGame重新开始/button /viewPage({ data: { target: 0, message: 游戏开始猜一个数字吧, count: 0, inputVal: }, onLoad() { this.initGame(); }, initGame() { const target Math.floor(Math.random() * 100) 1; this.setData({ target, message: 游戏开始猜一个数字吧, count: 0, inputVal: }); }, onInput(e) { this.setData({ inputVal: e.detail.value }); }, onGuess() { const { inputVal, target, count } this.data; const guess parseInt(inputVal, 10); if (isNaN(guess) || guess 1 || guess 100) { this.setData({ message: 请输入 1 到 100 之间的整数 }); return; } const newCount count 1; if (guess target) { this.setData({ message: 恭喜猜中答案就是 ${target}你用了 ${newCount} 次, count: newCount }); } else if (guess target) { this.setData({ message: 大了再往小猜, count: newCount }); } else { this.setData({ message: 小了再往大猜, count: newCount }); } } });这段代码里有个容易被新手忽略的点游戏状态目标数字、猜测次数必须放在 data 里但同时要注意——target是不希望展示给用户的敏感状态放在 data 里只是因为它方便管理。如果介意可以把target挂在this实例上this.target xxx这样既不会被 WXML 绑定也不会增加 setData 的开销。这个游戏的核心价值在于它帮你理解小游戏里最基础的“状态流转”——动作触发放到事件函数里事件函数修改数据数据驱动界面更新逻辑闭环非常干净。扩展玩法也很容易加计分系统、加关卡难度缩小数字范围、加时间限制都能在这套骨架上低成本实现。2.2 打地鼠小游戏Canvas 渲染与计时器控制接下来是打地鼠这是典型的 Canvas 渲染型小游戏。地鼠从洞中随机出现玩家点击地鼠加分游戏时间结束结算得分。Canvas 方案的核心在于画面不再是 WXML 描述的快照而是一帧一帧绘制出来的。每次地鼠的位置、大小、状态变化都要通过重新绘制反映到画布上。先看 WXML 部分只需要一个 canvas 和几个功能按钮view classcontainer canvas classgame-canvas canvas-idwhackCanvas bindtouchstartonTouchCanvas/canvas view classinfo text得分{{score}}/text text时间{{timeLeft}}s/text /view button classbtn bindtapstartGame开始游戏/button /viewJS 逻辑层是重点。游戏主循环我用setInterval做秒级计时但画面更新使用requestAnimationFrame驱动两者分工不同——前者管时间后者管渲染Page({ data: { score: 0, timeLeft: 30 }, onReady() { this.initCanvas(); }, initCanvas() { this.ctx wx.createCanvasContext(whackCanvas); this.canvasWidth 300; this.canvasHeight 400; this.isPlaying false; this.moles []; // 初始化 9 个洞的位置 for (let i 0; i 9; i) { const row Math.floor(i / 3); const col i % 3; this.moles.push({ x: 40 col * 80, y: 80 row * 80, width: 60, height: 60, active: false, showTime: 0 }); } this.drawScene(); }, startGame() { if (this.isPlaying) return; this.isPlaying true; this.setData({ score: 0, timeLeft: 30 }); this.timer setInterval(() { const left this.data.timeLeft - 1; this.setData({ timeLeft: left }); if (left 0) { this.endGame(); } }, 1000); this.spawnMole(); // 每 800ms 随机出现一只地鼠 this.moleSpawnTimer setInterval(() { if (this.isPlaying) this.spawnMole(); }, 800); this.animationFrame requestAnimationFrame(this.updateFrame.bind(this)); }, spawnMole() { // 随机选一个洞如果当前没有地鼠就激活 const idx Math.floor(Math.random() * 9); if (this.moles[idx].active) return; this.moles[idx].active true; this.moles[idx].showTime 0; }, updateFrame(timestamp) { if (!this.isPlaying) return; // 更新地鼠消失逻辑模拟 300ms 后消失 this.moles.forEach((mole) { if (mole.active) { mole.showTime 16; // 约每帧 16ms实际应该用时间差计算 if (mole.showTime 300) { mole.active false; } } }); this.drawScene(); this.animationFrame requestAnimationFrame(this.updateFrame.bind(this)); }, drawScene() { const ctx this.ctx; ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); // 绘制背景 ctx.setFillStyle(#3a7d44); ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight); this.moles.forEach((mole) { // 绘制洞 ctx.setFillStyle(#4a2f1b); ctx.beginPath(); ctx.arc(mole.x mole.width / 2, mole.y mole.height / 2, 30, 0, Math.PI * 2); ctx.fill(); // 地鼠出现时绘制地鼠 if (mole.active) { ctx.setFillStyle(#d4a373); ctx.beginPath(); ctx.arc(mole.x mole.width / 2, mole.y mole.height / 2 - 15, 20, 0, Math.PI * 2); ctx.fill(); ctx.setFillStyle(#333333); ctx.beginPath(); ctx.arc(mole.x mole.width / 2 - 10, mole.y mole.height / 2 - 25, 4, 0, Math.PI * 2); ctx.arc(mole.x mole.width / 2 10, mole.y mole.height / 2 - 25, 4, 0, Math.PI * 2); ctx.fill(); } }); ctx.draw(); }, onTouchCanvas(e) { if (!this.isPlaying) return; const touch e.touches[0]; const x touch.x; const y touch.y; // 命中检测 this.moles.forEach((mole) { if (mole.active x mole.x x mole.x mole.width y mole.y y mole.y mole.height) { // 打中 mole.active false; this.setData({ score: this.data.score 1 }); } }); }, endGame() { this.isPlaying false; clearInterval(this.timer); clearInterval(this.moleSpawnTimer); cancelAnimationFrame(this.animationFrame); wx.showModal({ title: 游戏结束, content: 你的得分是 ${this.data.score}, showCancel: false }); }, onUnload() { // 清理所有定时器防止内存泄漏 if (this.timer) clearInterval(this.timer); if (this.moleSpawnTimer) clearInterval(this.moleSpawnTimer); if (this.animationFrame) cancelAnimationFrame(this.animationFrame); } });这段代码有几个关键点值得展开第一计时器和渲染循环为什么要分开setInterval的最小执行间隔不稳定被系统调度干扰是常有的事用来驱动渲染会导致画面一顿一顿的。requestAnimationFrame是浏览器和小程序环境针对动画做了优化的调度机制帧率更稳定也节省 CPU。秒级倒计时这种低频逻辑用setInterval没问题但高频渲染一定要走requestAnimationFrame。第二ctx.draw()的位置别放错。wx.createCanvasContext是旧版 Canvas 接口所有ctx.setFillStyle、ctx.fillRect这类命令只是被记录下来真正绘制需要最后调一次ctx.draw()。如果忘调或者调的位置不对画布上就是空的或者上一帧的残留画面。第三命中检测我在代码里用的是地鼠矩形的包围盒判断虽然从坐标到地图绘制都用了圆形但碰撞算法用矩形近似是游戏开发里最常见的做法——判断简单、性能好、对休闲游戏来说准确度足够了。2.3 2048 小游戏矩阵算法与动画衔接2048 是逻辑密度最高的一个案例核心难点在滑动合并算法。虽然渲染层可以用 Canvas也可以退化为 WXML 网格一个数字格子对应一个 View但真正决定游戏好坏的是那套矩阵变换逻辑。2048 的完整源码比较长挑最核心的滑动合并逻辑来拆解。游戏的棋盘是一个 4x4 的二维数组比如const grid [ [2, 0, 0, 2], [4, 4, 0, 0], [0, 0, 8, 8], [2, 0, 4, 0] ];0 表示空格子。向左滑动时每一行独立处理处理逻辑是去掉行内所有的 0让相同数字相邻则合并合并后后面补 0。以[4, 4, 0, 0]为例向左滑动后变成[8, 0, 0, 0]以[2, 0, 0, 2]为例不是简单相加因为两个 2 之间隔着 0需要先压缩成[2, 2, 0, 0]再合并成[4, 0, 0, 0]。对应的 JavaScript 实现function mergeRow(row) { // 第一步去掉 0 let filtered row.filter(n n ! 0); // 第二步从左往右两两合并 for (let i 0; i filtered.length - 1; i) { if (filtered[i] filtered[i 1]) { filtered[i] * 2; filtered.splice(i 1, 1); // 每次合并记录加分 score filtered[i]; } } // 第三步补 0 到长度 4 while (filtered.length 4) { filtered.push(0); } return filtered; } function moveLeft(grid) { const newGrid []; for (let i 0; i 4; i) { newGrid[i] mergeRow(grid[i]); } return newGrid; }向右滑动时可以先反转每一行执行moveLeft再反转回来。向上向下同理通过矩阵转置行列互换把问题归一到“左移”一个函数这是 2048 算法里最漂亮的简化。完整思路function rotateGrid(grid) { // 顺时针旋转 90 度 const newGrid [[], [], [], []]; for (let i 0; i 4; i) { for (let j 0; j 4; j) { newGrid[j][3 - i] grid[i][j]; } } return newGrid; } function moveUp(grid) { // 上移 顺时针旋转后左移再逆时针旋转回来 let rotated rotateGrid(grid); let moved moveLeft(rotated); // 逆时针旋转 顺时针旋转 3 次 for (let i 0; i 3; i) { moved rotateGrid(moved); } return moved; }有了这套矩阵变换四个方向的滑动就统一成了一个方向。这就是 2048 源码里最值得学习的地方——不是渲染不是界面而是用抽象思维把复杂问题简化。实际开发 2048 时还有一个容易踩的坑判断滑动是否有效。如果一次滑动没有产生任何数字变化比如向左滑时本来就已经全部靠在左边且无相邻相等就不能生成新数字否则会白白消耗步数。判断方法也很简单滑动前深拷贝一份 grid滑动后逐项对比任何位置值变了才算有效。3. 从零到上线小游戏开发完整实操流程3.1 账号注册、类目选择与开发环境准备做微信小游戏开发第一件事不是写代码是注册账号。打开微信公众平台选择小程序或小游戏注册填写邮箱、主体信息然后进入后台完善资料。这里我强烈建议即使只是本地练手也用真实的 AppID 创建项目而不是用测试号。测试号很多能力受限比如部分 Canvas 接口、开放数据域、云开发用真实 AppID 才能完整走通全流程。类目选择上要注意正规上线的“小游戏”类目需要企业主体个人主体可以注册小程序但上不了小游戏类目。个人开发者可以先在小程序页面里练手逻辑完全通用等项目有企业主体再提审或迁移。开发环境二选一微信开发者工具 VS Code 的组合。微信开发者工具负责预览、调试、上传但代码编辑体验一般所以我习惯用 VS Code 写代码微信开发者工具只用来实时预览和真机调试。最新的开发者工具已经支持不错的断点调试功能遇到问题先加断点不要瞎猜。3.2 项目创建与关键配置文件创建项目时选择前端模板JavaScript 基础模板然后按目录结构组织代码。小游戏类目项目的入口是game.js配置在game.json普通小程序页面项目入口是app.js配置在app.json。如果你在普通小程序页面里做游戏记得在app.json里注册页面路径。game.json里比较重要的配置项包括{ deviceOrientation: portrait, showStatusBar: false, networkTimeout: { request: 10000 } }deviceOrientation控制横竖屏休闲小游戏一般竖屏横屏需要额外适配安全区域showStatusBar决定是否显示状态栏沉浸式游戏通常关掉。如果你的小游戏包含云开发能力还需要在project.config.json里配置cloudfunctionRoot指向云函数目录并且开通云开发环境。这块我建议小游戏先别上云等本地逻辑稳定了再加降低调试复杂度。3.3 真机调试与发布上线的检查清单模拟器跑通了不算数小游戏必须上真机调。真机上 Canvas 的表现和模拟器差异很大包括设备像素比dpr带来的清晰度问题、不同机型内存限制、触摸事件的坐标偏差。我的习惯是开发初期就多拿几台不同价位的手机测试尤其是性能较差的低端 Android 机卡不卡、闪不闪那才是小游戏真实水平的底线。上线前至少过一遍这个清单首屏加载时间是否在 3 秒以内超了就检查包体和资源加载逻辑主包体积是否在限制范围内超了要拆分包真机帧率是否稳定在 30 帧以上低于这个数字必须优化所有定时器和动画循环是否在页面销毁时清理干净音频是否在用户点击后播放自动播放大概率被拦截游戏结束后是否可以正常重新开始不会残留旧状态分享功能是否正常分享参数是否能正确传递到新会话发布流程不复杂在开发者工具点击上传填好版本号和备注然后在公众平台提交审核。审核一般一两个工作日但小游戏类目审核更严格素材必须合规不能有诱导分享、侵权行为。第一次提审被驳回很正常按驳回原因改就行不要头铁跟审核规则对着干。4. 常见问题与排查技巧实录4.1 性能掉帧与卡顿的优化方案小游戏最常见的性能问题是掉帧。我自己排查性能问题的顺序是先看是不是 setData 太频繁再看是不是 Canvas 绘制开销太大最后检查主循环里有没有多余的逻辑计算。普通小程序页面里做游戏最容易踩 setData 的坑。游戏每一帧都在变的数据比如地鼠的位置、得分数字如果你每帧都setData一次页面渲染层会非常吃力。优化手段是降低同步频率——界面上的数字可以隔几帧再更新而不是每帧都同步Canvas 里绘制的内容压根不需要 setData直接在ctx.draw()里更新就行。Canvas 绘制开销大通常是因为每帧都重新绘制了静态背景。解决办法是离屏缓存把不变的背景先绘制到一个离屏 canvas 上每帧只需要绘制变化的部分再把结果贴回去。这个技巧在粒子效果多的游戏里尤其明显能让帧率提升一个档次。还有一点requestAnimationFrame回调里不要做console.log特别是在真机上。真机控制台日志会拖慢帧率严重的还会导致画面卡顿几秒。真机调试时宁可用状态面板看数字也别打日志看变化。4.2 多机型兼容与交互适配小游戏在不同机型上的表现千奇百怪最典型的是 iPhone 和 Android 的触摸事件差异。部分 Android 机型在快速连点时会丢失 touch 事件的坐标数据导致玩家明明点中了地鼠却没得分。解决思路是用触摸开始事件而不是触摸移动事件来判定点击并且对快速连点做防抖处理比如两次有效点击之间间隔不小于 100ms。iOS 的 WebView 渲染机制和 Android 不一样Canvas 在 iOS 上默认使用 GPU 加速但某些旧版本 iOS 上离屏 canvas 的getContext(2d)可能返回 null需要降级处理。遇到这类诡异问题先搜 issue大概率是官方已知问题有对应的 workaround。还有个细节是安全区域。iPhone 的刘海屏和底部 Home Indicator 区域会遮挡游戏按钮写代码时用wx.getWindowInfo().safeArea旧版 API 是wx.getSystemInfoSync()获取安全区数据把关键交互元素放在安全区内别用固定像素布局。4.3 源码复用中的几个实操心得最后聊聊源码复用这件事。很多人下载了源码改了改名字和素材上传后发现一堆报错原因通常不是源码有问题而是没改干净。源码复用时最容易漏改的地方有三个一是project.config.json里的appid不改成自己的 AppID 根本编译不过二是game.json或app.json里的页面路径和分包配置源码项目里的路径和你的目录如果不一致会直接白屏三是全局搜索源码里的自定义事件名、云函数名称这些字符串散落在代码各处少改一处就是千奇百怪的 bug。拿到别人的小游戏源码我建议先跑起来再改别边看边改。先确保原版项目在本地能正常编译、预览再逐步替换素材、修改玩法每改一步就做一次真机测试这样问题好定位得多。我自己整理源码时习惯做一份 README把依赖项、运行环境、真机测试机型和注意事项写清楚。好的源码分享不仅是代码本身还包括别人读码时需要的上下文。这也是为什么我常说小游戏开发最难的不是写代码而是把代码写得别人能看懂、自己能维护。小游戏这个东西做起来比看起来有意思坑也比预想的多。但当你第一次在真机上流畅跑起来一个自己写的完整游戏那种满足感是普通业务页面给不了的。如果你正打算做自己的第一个小游戏不用想太复杂先从最简单的猜数字开始把状态流转跑通再逐步加特效、加音效、加玩法一步一步来比什么都强。
返回列表