ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

微信小程序消消乐开发实战:从Canvas绘制到游戏逻辑全解析

2026/8/23 22:19:20 拓冰建站 浏览量
微信小程序消消乐开发实战:从Canvas绘制到游戏逻辑全解析 1. 从零到一为什么选择微信小程序做消消乐如果你和我一样是个喜欢捣鼓点小东西的程序员或者是个想入门前端游戏开发的新手那么“方块消消乐”这个项目绝对是个绝佳的练手选择。它不像大型游戏那样需要复杂的引擎和庞大的团队但麻雀虽小五脏俱全涵盖了游戏开发的核心循环画面渲染、用户交互、游戏逻辑和状态管理。而微信小程序恰恰为这个“五脏俱全”的小游戏提供了一个近乎完美的舞台。为什么这么说首先微信小程序拥有庞大的用户基数你的作品可以轻松触达数亿用户无需他们下载安装点开即玩分享也极其方便。这对于验证一个游戏玩法、快速获得反馈来说是其他平台难以比拟的优势。其次小程序的开发框架尤其是游戏方向的Canvas API已经相当成熟它屏蔽了不同手机操作系统的底层差异让你可以专注于游戏逻辑本身。最后从技术栈上看它主要基于JavaScript对于前端开发者来说几乎没有额外的学习成本而对于新手这也是一个绝佳的、能产出可见成果的JavaScript实战项目。所以这篇内容我会以一个“过来人”的身份和你一起走一遍《方块消消乐》微信小程序的完整开发流程。我不会只给你一堆代码片段而是会重点讲清楚每个环节“为什么”要这么做以及我在实际开发中踩过的那些坑和总结出的技巧。我们的目标是让你不仅能复现出一个能玩的游戏更能理解其背后的设计思路未来可以举一反三创造出属于自己的小游戏。2. 项目蓝图核心玩法与架构设计在动手写第一行代码之前我们必须把游戏的“蓝图”画清楚。一个清晰的架构设计能让你在后续开发中事半功倍避免陷入“写到哪算哪”的混乱局面。2.1 核心玩法规则定义我们的《方块消消乐》基础规则很简单游戏区域一个由 M x N 个格子组成的矩形棋盘。方块元素每个格子内随机填充一种颜色的方块例如红、黄、蓝、绿、紫五种颜色。消除条件玩家点击一个方块如果其上下左右相邻我们暂不考虑斜角存在两个或以上同色方块则这些相连的同色方块会被消除。下落与填充方块消除后上方的方块会因重力下落填补空位然后棋盘顶部会随机生成新的方块填充空缺。游戏目标可以设定为限时内获得尽可能高的分数每消除一组方块获得分数消除的方块越多单次得分加成越高或者设定一个目标分数在步数或时间内达成。这里有一个关键决策点消除算法是“点击即消”还是“交换后消”经典如《开心消消乐》是交换两个相邻方块形成三连以上才消除。我们这里为了简化入门采用“点击消除相连同色块”的规则逻辑更直观。确定了核心规则我们才能设计数据结构。2.2 技术架构与文件结构微信小程序游戏开发主要涉及以下几个核心部分Canvas这是游戏的“画布”所有方块的绘制、动画效果都发生在这里。小程序提供了性能更好的Canvas组件和对应的CanvasContextAPI 进行绘图。JavaScript游戏的大脑。负责游戏状态管理用一个二维数组矩阵来表示棋盘数据。例如board[5][5] 3表示第6行第6列索引从0开始的方块类型是3对应紫色。游戏逻辑包括生成随机棋盘、检测点击位置、寻找相连同色块、计算消除、处理方块下落和填充新方块、计算分数等。动画与控制控制方块消除、下落、新方块出现的动画时序。WXML WXSS小程序的视图层。WXML用来布局Canvas组件和显示分数、按钮等UIWXSS用来美化这些UI元素。一个清晰的项目目录结构如下minigame-eliminate/ ├── pages/ │ └── game/ │ ├── game.js // 游戏主逻辑 │ ├── game.json // 页面配置 │ ├── game.wxml // 页面结构包含Canvas │ └── game.wxss // 页面样式 ├── images/ │ └── (存放方块贴图等图片资源) ├── app.js ├── app.json └── app.wxss我们把所有游戏相关的代码都放在pages/game/目录下保持模块化。2.3 状态数据模型设计这是游戏逻辑的基石。我们需要在game.js的data中定义核心状态// game.js - Page data 部分 data: { ctx: null, // Canvas 绘图上下文 board: [], // 核心的棋盘二维数组如 8x8 boardSize: 8, // 棋盘大小 tileSize: 0, // 每个方块的像素大小需根据Canvas尺寸计算 colors: [#FF5252, #FFEB3B, #2196F3, #4CAF50, #9C27B0], // 方块颜色 score: 0, // 当前分数 isProcessing: false, // 是否正在处理消除/下落动画用于防止玩家连续点击干扰动画 gameState: playing, // 游戏状态playing, paused, over },board数组是整个游戏的状态核心。初始化时我们需要用随机颜色索引0-4填充这个二维数组。记住永远不要让初始棋盘就存在可消除的组合否则游戏一开始就自动消掉一大片体验很糟糕。这需要在初始化算法中加入检测和重排逻辑。3. 核心战场Canvas绘制与用户交互实现游戏画面和操作响应是玩家最直接的感受。这一部分我们将深入 Canvas 的绘制细节和精准的事件处理。3.1 Canvas初始化与自适应绘制首先在game.wxml中放置 Canvas。这里有个关键点为了获得更好的性能我们使用type2d的 Canvas。!-- game.wxml -- view classgame-container canvas idgameCanvas type2d bindtouchstartonCanvasTap stylewidth: 100%; height: 70vh; /canvas !-- 分数和操作区 -- view classscore-board得分{{score}}/view button bindtaprestartGame重新开始/button /view注意我们绑定了bindtouchstart事件来监听玩家的点击或触摸事件。在game.js的onReady生命周期中我们需要初始化 Canvas 上下文并计算方块尺寸。// game.js onReady: function () { const query wx.createSelectorQuery() query.select(#gameCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node const ctx canvas.getContext(2d) const dpr wx.getSystemInfoSync().pixelRatio // 纠正Canvas实际渲染尺寸避免在高清屏上模糊 canvas.width res[0].width * dpr canvas.height res[0].height * dpr ctx.scale(dpr, dpr) const tileSize Math.min(res[0].width, res[0].height) / this.data.boardSize this.setData({ ctx: ctx, tileSize: tileSize }, () { this.initBoard() // 初始化棋盘数据 this.drawBoard() // 首次绘制 }) }) }这里有几个踩坑点Canvas尺寸与样式尺寸Canvas 的width和height属性是它的实际绘图缓冲区大小而 CSS 中的width/height是它显示的大小。如果不通过canvas.width width * dpr这种方式显式设置在高分辨率屏如 Retina 屏上绘制的内容会模糊。上述代码通过获取设备像素比dpr进行了纠正。tileSize的计算我们根据 Canvas 显示区域的宽高和棋盘格子数计算出每个方块应该占用的像素大小确保棋盘能完整且居中地绘制在 Canvas 内。3.2 绘制函数让数据变成画面drawBoard函数是连接数据 (board数组) 和画面 (Canvas) 的桥梁。// game.js drawBoard: function() { const { ctx, board, tileSize, colors } this.data if (!ctx) return // 1. 清空画布 ctx.clearRect(0, 0, ctx.canvas.width / wx.getSystemInfoSync().pixelRatio, ctx.canvas.height / wx.getSystemInfoSync().pixelRatio) // 2. 遍历棋盘数组绘制每一个方块 for (let row 0; row board.length; row) { for (let col 0; col board[row].length; col) { const tileType board[row][col] if (tileType null || tileType undefined) continue // 空位不绘制 const x col * tileSize const y row * tileSize // 绘制圆角矩形方块 ctx.fillStyle colors[tileType] this.roundRect(ctx, x 2, y 2, tileSize - 4, tileSize - 4, 6).fill() // 留2像素边距更美观 // 可选绘制方块内部高光或边框增加立体感 ctx.strokeStyle rgba(255, 255, 255, 0.3) ctx.lineWidth 1 this.roundRect(ctx, x 2, y 2, tileSize - 4, tileSize - 4, 6).stroke() } } }, // 封装一个绘制圆角矩形的工具函数 roundRect: function(ctx, x, y, width, height, radius) { ctx.beginPath() ctx.moveTo(x radius, y) ctx.arcTo(x width, y, x width, y height, radius) ctx.arcTo(x width, y height, x, y height, radius) ctx.arcTo(x, y height, x, y, radius) ctx.arcTo(x, y, x width, y, radius) ctx.closePath() return ctx }绘制时我习惯给方块内部留一点边距并加上一个浅色的描边这样在视觉上方块之间会有清晰的间隔看起来更像独立的“块”而不是色块拼接。这是提升游戏视觉精致度的一个小技巧。3.3 精准的点击事件处理玩家点击 Canvas 时我们需要将触摸点的坐标转换为棋盘上的行列索引。// game.js onCanvasTap: function(e) { if (this.data.isProcessing || this.data.gameState ! playing) { return // 如果正在播放动画或游戏未开始则忽略点击 } const touch e.touches[0] const query wx.createSelectorQuery() query.select(#gameCanvas).boundingClientRect(rect { // 计算点击位置相对于Canvas左上角的坐标 const x touch.clientX - rect.left const y touch.clientY - rect.top const col Math.floor(x / this.data.tileSize) const row Math.floor(y / this.data.tileSize) // 检查点击是否在棋盘有效范围内 if (row 0 row this.data.boardSize col 0 col this.data.boardSize) { this.handleTileClick(row, col) // 处理方块点击 } }).exec() }这里的关键是boundingClientRect方法它能获取 Canvas 组件在页面上的真实位置和大小。因为 Canvas 可能通过 CSS 缩放或存在边距直接使用e.touches[0].clientX/Y得到的是相对于整个窗口的坐标必须减去 Canvas 的左上角偏移量才能得到在 Canvas 内部的正确坐标。这是交互精准性的基础很多新手会在这里出错导致点击位置对不上。4. 游戏逻辑心脏消除、下落与填充算法这是游戏最核心的部分决定了玩法的正确性和流畅度。逻辑虽不复杂但细节处理不好很容易出现Bug。4.1 查找相连同色块Flood Fill算法当玩家点击一个方块(startRow, startCol)时我们需要找出所有与其直接或间接相邻的同色方块。这通常使用**深度优先搜索DFS或广度优先搜索BFS**算法也就是“泛洪填充”Flood Fill。// game.js findConnectedTiles: function(startRow, startCol) { const { board, boardSize } this.data const targetColor board[startRow][startCol] if (targetColor null) return [] // 点击的是空位 const visited Array.from({ length: boardSize }, () new Array(boardSize).fill(false)) const result [] const stack [[startRow, startCol]] // 使用栈实现DFS const directions [[1, 0], [-1, 0], [0, 1], [0, -1]] // 下上右左 while (stack.length 0) { const [r, c] stack.pop() if (visited[r][c]) continue visited[r][c] true result.push([r, c]) for (const [dr, dc] of directions) { const newR r dr const newC c dc // 检查新坐标是否在棋盘内且颜色相同且未被访问 if (newR 0 newR boardSize newC 0 newC boardSize !visited[newR][newC] board[newR][newC] targetColor) { stack.push([newR, newC]) } } } // 只有相连数量大于等于2至少两个才算有效消除组 return result.length 2 ? result : [] }这个函数返回一个包含所有相连同色方块坐标[row, col]的数组。注意我们设定了result.length 2才返回这意味着单个方块是无法被消除的符合我们的规则。4.2 处理消除与计分在handleTileClick中调用查找函数并处理消除。// game.js handleTileClick: function(row, col) { const connectedTiles this.findConnectedTiles(row, col) if (connectedTiles.length 0) { // 如果没有可消除的可以给一个提示比如方块抖动一下这里略过 return } // 1. 标记为处理中防止重复点击 this.setData({ isProcessing: true }) // 2. 计分简单的规则消除N个方块得 N * 10 分鼓励连消 const addScore connectedTiles.length * 10 this.setData({ score: this.data.score addScore }) // 3. 执行消除动画视觉反馈 this.animateRemoval(connectedTiles, () { // 动画结束后执行核心逻辑 this.removeTiles(connectedTiles) this.applyGravity() this.fillEmptyTiles() this.drawBoard() // 重新绘制 // 检查消除后是否产生了新的可消除组合连锁反应 this.checkForAutoMatches(() { // 所有连锁反应处理完毕解除处理锁 this.setData({ isProcessing: false }) }) }) }, // 移除方块将board中对应位置设为null removeTiles: function(tilePositions) { const newBoard [...this.data.board] for (const [r, c] of tilePositions) { newBoard[r][c] null } this.setData({ board: newBoard }) }这里引入了动画和连锁反应的概念。消除时如果直接让方块消失体验很生硬。我们可以先播放一个简单的缩放或淡出动画animateRemoval。更重要的是当一批方块消除、上方方块下落后可能会在棋盘中间或底部形成新的同色相连组合。我们需要递归地检测并消除这些新组合这就是“连锁反应”它能极大提升游戏的爽快感。checkForAutoMatches函数就是用来做这个的它本质上会遍历整个棋盘模拟点击每一个方块看看是否有可消除组。4.3 重力下落与顶部填充算法消除后留下的空位null需要由它上方的方块下落填补然后顶部再生成新方块。// game.js applyGravity: function() { const { boardSize, board } this.data const newBoard JSON.parse(JSON.stringify(board)) // 深拷贝一份进行操作 // 从最底部倒数第二行开始向上遍历每一列 for (let col 0; col boardSize; col) { let writeRow boardSize - 1 // 从该列最底部开始写入 for (let row boardSize - 1; row 0; row--) { if (newBoard[row][col] ! null) { // 如果当前行不是空位则将其移动到 writeRow 的位置 if (writeRow ! row) { newBoard[writeRow][col] newBoard[row][col] newBoard[row][col] null } writeRow-- // 写入位置上移一格 } } // 循环结束后writeRow 以上的位置包括writeRow现在应该全是空位 } this.setData({ board: newBoard }) }, fillEmptyTiles: function() { const { boardSize, board, colors } this.data const newBoard JSON.parse(JSON.stringify(board)) const colorCount colors.length for (let row 0; row boardSize; row) { for (let col 0; col boardSize; col) { if (newBoard[row][col] null) { // 随机生成一个颜色索引填充空位 // 注意这里可以加入简单逻辑避免生成后立刻形成三连但为了简单起见先随机 newBoard[row][col] Math.floor(Math.random() * colorCount) } } } this.setData({ board: newBoard }) }applyGravity是算法的一个小难点。我的实现思路是对每一列从下往上扫描用一个指针writeRow指向当前可以放置方块的位置。遇到非空方块就把它“塞”到writeRow的位置然后writeRow上移。这样一趟扫描下来空位自然就被挤到上面去了。这个算法非常高效只需要O(N²)的复杂度。5. 打磨体验动画、性能与进阶优化一个基础功能完备的游戏和一个体验流畅、令人愉悦的游戏之间差的就是这些打磨的细节。5.1 基础动画实现小程序中实现动画有几种选择使用requestAnimationFrame手动控制最灵活性能也好但代码量稍大。使用小程序自带的AnimationAPI更声明式适合简单的属性动画。使用 CSS3 动画对于非Canvas的UI元素如分数飘字很合适。对于方块消除和下落的动画由于涉及大量元素且与Canvas绘制强相关使用requestAnimationFrame是更合适的选择。以下是一个简单的消除缩放动画示例// game.js animateRemoval: function(tilePositions, callback) { const { ctx, tileSize, colors } this.data let scale 1 const animate () { scale - 0.05 // 每一帧缩小5% ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height) // 先绘制所有未消除的方块 this.drawStaticBoard() // 再绘制正在消除的方块带缩放 ctx.save() for (const [r, c] of tilePositions) { const x c * tileSize tileSize / 2 const y r * tileSize tileSize / 2 ctx.translate(x, y) ctx.scale(scale, scale) ctx.translate(-x, -y) ctx.fillStyle colors[this.data.board[r][c]] this.roundRect(ctx, c * tileSize 2, r * tileSize 2, tileSize - 4, tileSize - 4, 6).fill() ctx.resetTransform() // 重置变换为下一个方块准备 } ctx.restore() if (scale 0) { requestAnimationFrame(animate) } else { callback() // 动画结束执行回调 } } requestAnimationFrame(animate) }, // 绘制未被消除的静态方块 drawStaticBoard: function() { const { ctx, board, tileSize, colors } this.data // ... 遍历board只绘制非消除位置的方块 }下落动画则更复杂一些需要记录每个下落方块的目标行和当前动画帧的垂直位置在每一帧中更新位置并重绘。一个重要的经验是将动画和核心数据更新分离。动画只负责视觉表现在动画结束后再调用removeTiles,applyGravity等函数更新真正的board数据。这样逻辑更清晰也便于调试。5.2 性能优化要点当棋盘变大如10x10或连锁反应很多时性能可能成为瓶颈。以下是一些优化方向减少不必要的重绘drawBoard函数会重绘整个棋盘。在动画过程中我们可以只重绘发生变化的部分脏矩形渲染但这在消消乐这种全局变化频繁的场景下实现复杂。一个更实际的优化是使用离屏Canvas。我们可以将静态的背景、网格线等绘制到一个离屏Canvas上每帧只将离屏Canvas内容复制到主Canvas然后再绘制动态的方块。小程序Canvas 2d支持ctx.drawImage绘制另一个Canvas可以利用这一点。避免在动画循环中进行复杂计算像findConnectedTiles这样的搜索算法不要在requestAnimationFrame的回调里执行。动画循环应只负责根据当前时间更新位置和重绘。对象池频繁创建和销毁对象如动画对象会产生垃圾回收可能引起卡顿。可以预创建一组对象循环使用。简化绘制命令在drawBoard中减少绘图状态的切换如fillStyle,strokeStyle的改变。可以尝试将同色的方块批量绘制。5.3 游戏性扩展思路基础版本完成后你可以考虑加入更多元素让游戏更好玩特殊方块比如“炸弹”点击后消除周围一圈、“彩虹球”可以匹配任何颜色。障碍物不可移动或需要多次点击才能消除的冰块、锁链等。关卡目标不再是单纯计分而是要求在一定步数内消除指定数量的某种颜色方块。粒子效果消除时迸发彩色粒子增加视觉冲击力。这可以在Canvas上用简单的小圆点动画模拟。音效与震动利用小程序的wx.playAudio和wx.vibrateShortAPI为消除、连锁等操作添加反馈体验立刻提升一个档次。6. 调试、发布与避坑指南开发完成并不意味着结束调试和发布环节同样充满“坑点”。6.1 微信开发者工具调试技巧Canvas调试在开发者工具的调试器中可以选中Canvas组件查看其属性。但更有效的是使用console.log输出关键的坐标、状态信息。例如在onCanvasTap中打印出计算出的(row, col)确保点击坐标转换正确。性能面板使用开发者工具的“性能”面板录制一段游戏操作查看帧率FPS。理想情况应保持在60fps。如果帧率过低分析是JavaScript执行时间过长“脚本”耗时高还是渲染时间过长。真机调试务必在真机上测试开发者工具模拟器的性能、触摸事件与真机有差异。特别是Canvas渲染和触摸响应在真机上才能反映真实情况。使用“真机调试”功能手机扫码即可在开发者工具中看到手机端的Console日志。6.2 常见问题与解决方案Canvas绘制内容不显示或闪烁检查Canvas尺寸确保canvas.width和canvas.height被正确设置并且不是0。这是最常见的问题。绘制时机确保drawBoard是在Canvas上下文ctx已经成功获取在onReady回调中并且棋盘数据board初始化后才调用。可以在drawBoard开头加一个if (!ctx || board.length 0) return的判断。双缓冲与闪烁如果动画闪烁可能是由于在清除画布到绘制完成之间屏幕进行了刷新。使用requestAnimationFrame本身就是为了解决这个问题确保所有绘制都在同一帧内完成。触摸事件响应区域错位这几乎100%是由于计算点击坐标时没有正确考虑Canvas的CSS偏移rect.left,rect.top。务必使用boundingClientRect方法获取精确位置。检查Canvas的CSS是否设置了margin,padding或transform这些都会影响boundingClientRect的结果。游戏逻辑Bug如方块无法消除、下落错乱数据不可变性JavaScript中数组是引用类型。像const newBoard this.data.board这样的操作修改newBoard会直接修改this.data.board导致状态混乱。务必使用深拷贝const newBoard JSON.parse(JSON.stringify(this.data.board))或使用扩展运算符配合map进行多层拷贝。递归爆栈在checkForAutoMatches实现连锁反应时如果设计成递归检测在极端情况下整个棋盘不断连锁可能导致调用栈溢出。可以考虑用循环队列的方式BFS来替代递归。使用调试器在疑似出错的函数如findConnectedTiles,applyGravity中设置断点逐步执行观察变量状态。6.3 提交审核与发布完善体验确保游戏有明确的开始、结束界面有重新开始按钮。添加简单的游戏说明。测试兼容性在iOS和Android的不同机型、不同微信版本上测试。特别注意低端机型的性能表现。准备素材需要准备小程序图标、简介、至少一张预览图。游戏类小程序审核可能会更关注内容合规性确保没有违规内容。提交审核在微信公众平台提交代码审核。描述清晰必要时可在备注中说明这是一个简单的休闲益智游戏。迭代更新发布后收集用户反馈。微信小程序支持灰度发布和分阶段发布你可以先让小部分用户体验新版本稳定后再全量。走到这一步你的《方块消消乐》就已经从一个想法变成了一个真正可以运行、可以分享给朋友玩的微信小程序了。这个过程里你实践了从数据结构设计、Canvas渲染、事件处理到游戏逻辑算法的完整链条。更重要的是你学会了如何在一个具体的平台约束下小程序去解决一个个具体的问题。这种能力远比单纯复制一段代码更有价值。接下来试着给你的游戏换个皮肤增加一个“炸弹”道具或者设计10个有趣的关卡吧创造的过程才刚刚开始。