
微信小程序里的彩色消除项目最常被低估的不是“消除”两个字而是从一块静态画布变成能连续玩的完整小游戏要经历多少层问题。颜色方块布局、点击判定、同色连通块检测、下落和填充、计分和关卡、本地存档再加上微信小程序的发布和真机测试完整走一遍才能算学会。这篇文章适合两类人一是想练手微信小游戏开发的初学者二是有过 Web 开发经验、但第一次接触微信小程序 Canvas 和生命周期项目的开发者。我会按照实际从零搭一个彩色消除小游戏的顺序来写不跳过工具切换和调试时的坑。1. 彩色消除游戏的第一步把玩法和实现方式定下来1.1 先想清楚你要做哪一种消除玩法微信小程序里的“彩色消除”不是一个固定玩法名词。我见过几种常见版本三消类似常见的糖果消除游戏滑动交换相邻的两个色块凑齐三个或以上同色就消除。点选消除点击一个彩色方块把与它上下左右相邻的相同颜色方块一起消除。行列消除只要整行或整列包含同色匹配就能消除。物理下落消除消除后通过重力下落形成新的布局。选择哪种玩法必须先定下来。因为每一种对应的数据结构和算法完全不同。三消需要写交换逻辑和横竖判定点选消除只需要从点击位置往外扩散搜索行列消除则要检查每一行每一列的状态。我的建议是从“点选消除”入手逻辑最直接演示效果好做出来有很明确的成就感。这也是这篇文章后面要展开的版本。为什么强调先定玩法因为很多新手第一步就去找代码看到一块 Canvas 就开始画结果画到一半发现要处理交换、动画、回退工作量完全失控。先把玩法规则写成一两句话再思考数据结构和界面项目推进会顺很多。1.2 原生小程序还是游戏引擎如果目标就是做一个 2D 彩色消除小游戏建议直接用微信小程序原生 Canvas 2D。原因有三个包体积小。小程序主包限制是 2MB虽然可以通过分包扩展到更大但一个不需要物理引擎和 3D 渲染的消除游戏原生实现足够轻。学习路径短。不需要引入 Cocos Creator 或 Phaser 后再学一遍发布到微信的流程。生态兼容好。登录、分享、订阅消息、开放数据域这些微信能力原生接起来更快。那什么时候用游戏引擎如果你后续想做更复杂的粒子效果、关卡地图、大量动画或者想同一套代码发布到抖音、淘宝等其它小程序平台再考虑引擎。引擎的优势是跨平台和可视化编辑器换来的是构建复杂度、上传体积和调试成本。对单个彩色消除项目来说这些成本可能超过收益。这个阶段常被忽略的是“微信小程序游戏开发”这个身份判定。小游戏和普通小程序在平台上是两个类目注册时就要确认。如果你只想做一个普通小程序页面里内嵌的消除玩法那走小程序类目即可如果目标是发布到“小游戏”分类后台主体和类目要求不一样。我建议先按普通小程序做原型跑通之后再判断是否需要单独申请小游戏类目。1.3 第一版不要追求完整功能彩色消除第一版只需要三个页面一个开始页、一个游戏页、一个结算页。其他统统不做。开始页只放游戏标题和“开始”按钮。游戏页的核心闭环是玩家看到棋盘点击一个彩色方块如果相邻有同色块就被消除然后下落补齐。结算页只显示本局得分和最高分。这个闭环跑通之后你再判断要不要做动画、音效、道具、关卡。逻辑很简单但很重要项目早期每多一个功能调试范围就会变大。动画会掩盖数据错误音效会掩盖启动问题道具会引入新的边界条件。第一版做得越简单你越容易定位到底是算法问题还是渲染问题。我建议在开发工具里先用空白棋盘测试手动在控制台打印 board 变化。检查每一步的数据对不对再配合 Canvas 绘制。数据正确后再把随机初始化和所有交互接进来。2. 棋盘数据结构和消除算法是玩法地基2.1 用一个二维数组管理所有方块画布上的每一个彩色方块在逻辑层都应该对应一个二维数组元素。常见定义如下const ROWS 8; const COLS 8; const COLORS [#FF6B6B, #4ECDC4, #45B7D1, #F9CA24, #A29BFE]; // board[row][col] 存颜色下标-1 表示空位 let board Array.from({ length: ROWS }, () new Array(COLS).fill(0)); function randomBoard() { for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { board[r][c] Math.floor(Math.random() * COLORS.length); } } }这里给的是通用示例实际项目里可以按自己的行列数和颜色数量调整。为什么使用颜色下标而不是直接存十六进制字符串因为算法层面对颜色做匹配、判断、重新生成时下标比较比字符串比较更高效绘制时再映射成颜色值逻辑更干净。棋盘大小也要先想清楚。8×8 是折中的选择太小会让玩法变化少太大会让点击区域变小真机上手指容易误触。实测下来普通手机竖屏 8×8 或 8×10 比较合适横屏或平板可以适当增加。初始化时还要注意一个问题随机生成的棋盘有可能“开局即死局”也就是整张棋盘上找不到任何可以消除的连通块。所以生成棋盘后应该先扫描一遍如果没有任何可消除块就重新生成。后面会写扫描函数。2.2 用 BFS 找点击位置附近的同色连通块点选消除的核心是用户点击一个坐标系统从这一点出发向上下左右四个方向扩散收集所有颜色相同的方块形成一组“连通块”。如果连通块数量大于等于 2就执行消除小于 2则只做点击反馈不消除。这里最常用的算法是 BFS广度优先搜索也可以叫 Flood Fill。用队列实现示例代码如下function findConnected(r, c, visited) { const target board[r][c]; const queue [[r, c]]; const result []; visited[r][c] true; while (queue.length) { const [cr, cc] queue.shift(); result.push([cr, cc]); const dirs [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (const [dr, dc] of dirs) { const nr cr dr; const nc cc dc; if (nr 0 || nr ROWS || nc 0 || nc COLS) continue; if (visited[nr][nc]) continue; if (board[nr][nc] target) { visited[nr][nc] true; queue.push([nr, nc]); } } } return result; }为什么用 BFS 而不是直接遍历整张棋盘因为消除规则只要求“与点击位置相邻”的同色块不是全图扫描。BFS 可以准确拿到一个连通区域也让后续容易扩展出“最大连通块”“连锁消除”等逻辑。这里也有一个新手容易踩的坑queue.shift()在数组较长时性能不高因为每次 shift 都会移动元素。棋盘不大时可以忽略但如果棋盘是 20×20 或更大或者你要做频繁的连通检测建议改用“维护一个头指针”的方式代替 shift或者直接用 index 遍历。拿到连通块之后执行消除就是把对应位置置为 -1function removeBlocks(blocks) { blocks.forEach(([r, c]) { board[r][c] -1; }); }这一步不要直接在 BFS 过程中修改棋盘因为如果后续要支持预览动画先高亮再消除你需要保留原始的连通块列表逐帧播放。2.3 下落填充、连锁判定和死局检测消除之后空位会出现游戏要按照重力规则让上方方块下落并补新的随机方块。这里最容易写乱。建议分两步列内下落每一列从下往上扫描把所有非空方块依次往下放空位补 0 或重新随机。整体填充对于棋盘顶部的新空位重新生成新颜色方块。用这段结构表示function applyGravity() { for (let c 0; c COLS; c) { let writeRow ROWS - 1; for (let r ROWS - 1; r 0; r--) { if (board[r][c] ! -1) { board[writeRow][c] board[r][c]; if (writeRow ! r) board[r][c] -1; writeRow--; } } for (let r writeRow; r 0; r--) { board[r][c] Math.floor(Math.random() * COLORS.length); } } }为什么从下往上而不是从上往下因为如果从上往下处理下落时会覆盖还没处理的方块。从下往上写每一列的目标位置只会被赋值一次。下落完成后可能因为新方块加入棋盘上出现了新的可消除连通块。如果新生成的连消也自动消除就叫连锁。要不要支持连锁要看玩法要求。我建议第一版先支持“用户点击消除后检查棋盘是否自动产生新的可消除块如果有仅提示不自动消让用户自己点击”。逻辑更可控也方便调试。死局检测也要单独写扫描整张棋盘找出所有连通块数量如果没有任何连通块大于等于 2就是死局。这时候需要重新洗牌或者弹出提示让用户重开。死局检测和 BFS 用的核心函数一致只是从每个点出发做一次访问标记避免重复遍历。function hasValidMove() { const visited Array.from({ length: ROWS }, () new Array(COLS).fill(false)); for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { if (visited[r][c]) continue; const blocks findConnected(r, c, visited); if (blocks.length 2) return true; } } return false; }算法层到这里其实已经能在一段 Node.js 或浏览器控制台里跑通整个“点击-消除-下落-填充”流程。我建议先把这一层做好再写界面。因为界面改动频繁算法层一旦稳定后面接 Canvas 和事件会顺利得多。3. 用 Canvas 绘制棋盘触摸坐标和动画都要处理干净3.1 初始化 Canvas 和计算格子尺寸微信小程序里的 Canvas 有两种使用方式旧版 Canvascanvas canvas-idxxx和 Canvas 2Dtype2d。新项目建议直接用 Canvas 2D后续维护更好。下面是一个初始化示意view classgame-wrap canvas type2d idgameCanvas classgame-canvas/canvas /viewlet canvas null; let ctx null; wx.createSelectorQuery() .select(#gameCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res || !res[0] || !res[0].node) return; canvas res[0].node; ctx canvas.getContext(2d); const { width, height } res[0]; canvas.width width; canvas.height height; // 根据实际画布宽高计算格子尺寸 cellSize Math.floor(Math.min(width / COLS, height / ROWS)); });为什么要在查询节点后设置 canvas 宽高因为小程序里 Canvas 的宽高可能不等于渲染设备实际像素尺寸特别是高分屏设备不处理会出现模糊。Canvas 2D 的node存在后通常还需要把 width 和 height 同步成实际物理像素大小。格子尺寸建议用Math.floor取整。如果用浮点数绘制不同机型上相邻格子之间可能出现缝隙或重叠。取整后还能让点击坐标到行列的换算更稳定。3.2 绘制方块与触摸事件绘制棋盘是一个纯函数输入 board输出画面。每次数据变化后重新调用不要在绘制函数里改数据。基础绘制function drawBoard() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { const colorIndex board[r][c]; if (colorIndex -1) continue; ctx.fillStyle COLORS[colorIndex]; ctx.fillRect(c * cellSize, r * cellSize, cellSize - 2, cellSize - 2); } } }为了看得更清楚我习惯在格子之间留 1 到 2 像素间隙也就是绘制时把宽高缩小一点。这样整个棋盘更有“格子感”也不会因为相邻格子颜色相同导致边界看不清。触摸事件要绑定在 Canvas 节点上拿到触摸点坐标后再换算到棋盘行列canvas.addEventListener(touchstart, (e) { const touch e.touches[0]; const x touch.x; const y touch.y; const col Math.floor(x / cellSize); const row Math.floor(y / cellSize); if (row 0 || row ROWS || col 0 || col COLS) return; handleTap(row, col); });这里要特别提醒Canvas 2D 的 touch 事件坐标不是拿e.touches[0].clientX需要确认节点坐标系。如果按 clientX 算当 Canvas 不在屏幕左上角时点击位置会出现偏移。我一般用touch.x和touch.y或者通过wx.createSelectorQuery().boundingClientRect拿到 Canvas 的偏移值再减去。3.3 动画回放和 setData 的取舍很多新手在写消除动画时会频繁用this.setData更新数据再让 WXML 里的循环重渲染。这样做不是不行但棋盘变成 8×10、一屏有 80 个彩色块时频繁 setData 会导致明显卡顿尤其低端安卓机。正确思路是数据变化只放在逻辑层绘制层用 Canvas 直接操作。先消除再下落再填充这个动画过程可以用定时器或requestAnimationFrame分帧触发。比如先绘制“高亮选中”等 100ms再绘制“消除后的空位”然后每隔 100ms 让重力下落一帧。requestAnimationFrame在小程序 Canvas 2D 里可用但要注意小程序切到后台后定时器会被挂起。可以监听onHide和onUnload暂停动画回到前台时再恢复。否则会出现用户切走再切回来动画状态已经错乱的情况。初版如果没有动画体验会显得很生硬但也不要为了动画引入复杂系统。我的经验是先做无动画版本确认玩法和结算逻辑正确再逐层加消除、下落动画。每一步改动都能独立验证调试成本低很多。4. 从能玩到好玩关卡、计分、音效、存档和分享4.1 给游戏加上关卡目标单纯让棋盘无限循环很容易让玩家失去目标。给彩色消除加上“关卡”后体验会完整很多。关卡数据可以设计成下面这种结构const levelConfig { 1: { targetScore: 200, steps: 15, boardRows: 6, boardCols: 6, colorCount: 4 }, 2: { targetScore: 500, steps: 18, boardRows: 7, boardCols: 7, colorCount: 5 }, 3: { targetScore: 900, steps: 20, boardRows: 8, boardCols: 8, colorCount: 5 } };每关可以设置目标分数、步数限制、棋盘大小、颜色数量。这样同一套棋盘代码可以复用到不同关卡只需要在进入关卡前读取配置重新生成棋盘。关卡配置不需要一开始就做完整编辑器。先在代码对象里写 10 关等玩法测试稳定后再考虑用脚本自动生成关卡表。颜色数量是调节难度的重要参数颜色越少越容易形成大的连通块单次消除得分越高。4.2 计分、连击和简单道具计分公式可以先做成单次消除得分 消除方块数 × 10。比如一次消掉 5 个方块得分 50。如果想增加爽快感可以在一次操作中检测到棋盘上同时存在多组可消除块时把全部消除并给额外连击加成。道具系统是这个类型最容易加的部分。常见道具包括炸弹指定位置周围 3×3 区域全部消除。换色把棋盘上所有某色方块变成另一种颜色。撤回回退上一步操作。第一版不建议做道具先把步数和目标分数的循环跑通。做道具要记住一个原则道具是一个独立的数据层操作不能直接改 Canvas 的像素而是改 board 后重新绘制。例如炸弹就是遍历周围区域把对应格子置为空位然后走一遍下落函数。如果后续打算卖道具或者做支付能力还需要单独处理微信支付和广告组件这些能力都涉及后台资质和审核规则不是单纯改代码就能解决的问题。4.3 本地存档和用户信息游戏进度一般用wx.setStorageSync保存到本地比如当前关卡、得分、步数、解锁状态。存储时要注意序列化棋盘数据不必整局保存只需要保存关键进度。退出再进入时从本地恢复关卡进度重置棋盘或者继续上一局。这里有一个和实际开发关联很紧的坑很多人在登录时调用wx.getUserProfile获取微信用户信息结果在真机或部分版本上会失败报类似“小程序获取登录后的微信用户失败”。最可能的原因有三个用户拒绝授权、开发者工具里模拟登录状态不完整、基础库版本过低。另外新版平台已经调整了头像昵称获取规则不应该再依赖getUserProfile拿完整头像和昵称而是使用“头像昵称填写能力”由用户主动填写头像和昵称。彩色消除游戏里如果要做历史最高分和排行榜建议先做本地最高分涉及排名展示时再单独设计用户信息方案。4.4 音效、动效和分享按钮小程序内部播放音效可以用wx.createInnerAudioContext()也可以直接管理 Audio 实例。背景音乐建议在用户第一次点击“开始游戏”之后再开启避免用户还没进入游戏就被播放声音。小程序对自动播放有比较严格的限制很多机型上不进行交互操作就播放音频会被拦截。音效文件不要太大比较常用的是 MP3 格式时长控制在 1 秒内大小几十 KB。一局游戏的连续消除、获得新纪录、关卡通关分别用不同短音效反馈会更明确。分享也是小游戏很关键的一环。微信小程序可以设置onShareAppMessage定义分享卡片标题和图片。彩色消除这种休闲玩法天然适合“邀请好友来挑战我的分数”这种分享场景。分享回包里的scene可以用来识别用户是从哪个群或好友卡片进入这套逻辑在普通小程序里是常驻能力接入成本不高。注意不要在分享文案里写诱导性内容平台审核会处理“分享必得奖励”这类玩法。5. 预览、真机调试和发布上线这一关不能跳过5.1 体验版和真机调试流程开发工具里跑通后还需要经历上传代码、生成体验版、真机测试、提审这几个环节。第一次做小程序时最常犯的错是只在小程序开发者工具里点击预览以为扫码能看就行。实际上“预览”和“真机调试”是两个入口用途不一样预览生成一个临时二维码手机扫码后打开的是当前开发代码的在线版本适合快速查看。真机调试在手机端打开调试模式可以连接 VConsole 查看运行日志适合排查真机上才出现的问题。真机上遇到net::err_connection_reset这类报错时不要先怀疑游戏逻辑。先按顺序排查手机和电脑是否在同一网络开发者工具热重载服务是否稳定。请求的接口域名是否已在微信后台配置合法域名开发环境可以勾选“不校验合法域名”正式版不会自动忽略校验。是否在 HTTPS 请求中携带了不规范的请求头或未按编码要求处理的内容尤其是content-type相关这类问题在普通页面里不常见在小游戏开发时反而容易遇到。代码是否刚改过 Canvas 初始化canvas 节点还没有 ready 就立刻调用 drawBoard。另外很多小程序真机问题其实和基础库版本有关。开发者工具里模拟的是最新基础库真机上可能是微信自带的旧基础库。发布前建议用真机扫体验版二维码而不是只看开发者工具。5.2 发布前检查项清单可以按这个顺序逐项检查移除所有console.log或条件性保留错误日志。确认app.json里的页面路径、窗口标题、图标都正确。确认合法域名、业务域名、下载域名已经配置线上环境不再勾选“不校验合法域名”。确认用户隐私保护指引已经填写。涉及用户头像、昵称、位置、相册等能力时平台会强制要求说明用途。确认代码包体积在合理范围。超过限制时用分包处理核心玩法页面放主包音频、素材放分包。用开发者工具“上传”版本然后在微信公众平台后台设置体验版邀请至少两部手机实测。这里要特别提醒个人主体小程序能做的类目是有限制的。如果你的彩色消除小游戏要作为“小游戏”类目上线通常需要额外的资质材料。不同年份、不同主体的规则会有变化建议在微信公众平台查看当前类目资质要求不要根据网上的旧教程直接申请。5.3 开发者工具常见报错Windows 上有时会遇到这样的报错[微信小程序开发者工具] maximum setlocal recursion level reached.这个报错看起来像小程序代码问题实际更常见的原因是开发者工具所在环境或系统环境变量异常导致启动失败。解决办法一般是重新安装开发者工具检查 PATH 变量或者用管理员权限运行。遇到时先别去改小程序代码。还常见的报错是content-type无法置空。很多请求封装库会在 header 里强制添加content-type如果你在wx.request里设置header: { content-type: }某些情况下不生效。这是因为微信对部分 header 字段有框架级处理你需要使用官方允许的方式或者检查请求库版本。一般来说上传文件用uploadFile普通请求用request不要在两种接口之间混 header。另一个容易被忽略的问题分包配置后本地开发工具一切正常但真机上提示找不到分包页面。这是因为分包路径必须在app.json的subpackages里写完整并且入口页面不能放在分包内。如果入口页面就是游戏页只能走主包或把入口改为一个跳转页。6. 后续优化和生产化要考虑的细节6.1 包体积与素材加载彩色消除的核心逻辑本身很小真正占体积的是音效、背景图