
做了几年微信小程序拼图这种交互我前前后后写过好几种九宫格、滑块验证码风格的拼图、拖拽摆放的拼图游戏。说句实在话这功能在小程序里跟 Web 端完全是两回事图片过不来、Canvas 尺寸算错、真机上触摸偏位哪个坑我都踩过。这篇文章就把我最近做的一个九宫格拼图完整拆开从 Canvas 切图、乱序算法、触摸交换到胜负判定全部用原生组件实现不依赖第三方库把代码和思路都摆出来。不管你是要从零开始做个拼图小游戏还是在运营活动里加个拼图玩法这篇都能直接抄作业。1. 需求分析和技术选型先想清楚再做1.1 拼图功能不止一种先分清你要做哪种拼图这个词在小程序场景里其实覆盖了好几种完全不同的交互形态我刚开始接需求的时候也差点搞混。这里先帮你梳理清楚避免做了一半才发现方向不对。第一种是经典九宫格拼图也叫滑块拼图。图片被切成 3x3 或 4x4 的网格随机打乱玩家通过点击交换或者拖拽交换把图片拼回原样。这种形态最常见适合做休闲小游戏、活动裂变玩法也是本文要重点实现的类型。第二种是滑块验证码拼图。背景图上有一块缺口旁边有一块形状碎片玩家把碎片拖到缺口位置。这种本质上是交互验证很多活动页用它来过滤机器操作也兼顾一点品牌展示效果。第三种是自由拖放拼图把若干独立的图片碎片拖到固定的底板凹槽里类似儿童益智拼图。这种在小程序里比较少因为手指拖拽精度有限通常做成点击选中再点击放置容错率更高。我在做需求评审时一般会先问清楚三件事图片从哪来、目标交互是点击还是拖拽、成品是游戏还是功能。这三点直接决定技术方案的差异。比如纯游戏可以用 Canvas 整块绘制省事但对触摸精度要求高运营活动通常用 view 加背景图承载单个碎片交互稳、动画好做。1.2 技术方案对比Canvas 全绘制 vs View 加背景图确定做九宫格之后下一步是选实现载体。我在开发时评估过三种方案各有取舍。方案一全部交给 Canvas 渲染。整个拼图画布作为一个 Canvas 节点在内部绘制所有碎片、处理触摸事件和坐标换算。优点是只用一个节点视觉统一缺点是触摸事件要自己算坐标偏移、自己维护每个碎片的矩形区域而且要频繁重绘微信小程序的 Canvas 在低端机上的性能并不理想容易掉帧。方案二先 Canvas 切图导出再用 View 加背景图承载碎片。每个碎片是一个普通的 view背景图指定为从原图切出的那一小块触摸事件天然绑定在每个碎片上交换就是调一下数组顺序再加个动画。这种方案交互稳、代码清晰、动画也好做是个人项目最推荐的方式。方案三直接用第三方组件库比如 cropperjs 移植版或者 wx-cropper 一类的轮子。优点是省事裁剪功能完整缺点是拼图玩法依赖裁剪库的场景很有限库本身为了通用性带了很多用不上的代码体积和定制成本都不划算。我最后选了方案二只把 Canvas 当切图工具用碎片展示和交互全部交给普通 view。思路是在 Canvas 上把原图按 3x3 网格切出 9 张临时图片然后返回给页面数据层由 WXML 渲染出 9 个可交互的格子。这样既避开了 Canvas 触摸计算的复杂度也保留了 Canvas 精准切图的优势。1.3 整体流程设计一图理清数据流转整个功能跑起来的流程是这样的页面加载后加载一张本地图片或网络图片到 Canvas按图片实际像素尺寸计算每个碎片的大小用 drawImage 把每个格子单独绘制并导出成临时文件。导出完成后把 9 个碎片的路径扔进一个数组每个元素记录当前所在位置和正确位置。然后通过洗牌算法把数组顺序打乱渲染到页面上。玩家每交换一次就检查一下数组是否恢复成有序状态恢复即胜利。这个流程里最容易出问题的有两个环节一是 Canvas 导出图片的尺寸和清晰度二是乱序算法生成的可解性。这两个点我会在后面的章节单独展开都是实战中踩过坑才总结出来的。2. 核心原理拆解图片加载与 Canvas 精准切图2.1 新版 Canvas 2D 接口才是正确姿势微信小程序的 Canvas 有两种写法老版本用 CanvasContext 那一套通过 wx.createCanvasContext 获取上下文API 风格偏命令式。新版本支持 Canvas 2D 接口类似浏览器里的标准 Canvas通过 type2d 声明节点再用 SelectorQuery 拿到 node。我强烈建议直接用新版 Canvas 2D 接口。原因有三个第一CanvasContext 那套接口已经被官方标记为不推荐很多新能力只在 2D 接口上支持第二2D 接口的 drawImage、getContext 用法跟 Web 端基本一致你从网上参考 Canvas 代码时基本能直接迁移第三在性能上2D 接口渲染效率明显更高尤其是连续绘制多张小图时。获取节点的代码我贴在下面注意必须在 onReady 生命周期里执行因为那时候 canvas 节点才真正渲染完成。let query wx.createSelectorQuery() query.select(#puzzleCanvas) .fields({ node: true, size: true }) .exec((res) { let canvas res[0].node let ctx canvas.getContext(2d) let canvasWidth res[0].width let canvasHeight res[0].height // 这里 canvas 就是可用的画布对象ctx 就是绘制上下文 })有一个特别容易踩的坑canvas 节点的宽高要在 WXML 里写死同时还要保证 canvas 内部的分辨率尺寸和节点显示尺寸一致否则绘制的图会变形。我在项目里一般用固定像素值不搞自适应因为切图需要精确控制分辨率屏幕适配放在碎片的 view 层去处理。2.2 drawImage 的九参数切图法Canvas2D 的 ctx.drawImage 有三种传参方式切图场景用的是九参数版本ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)这里的九个参数分两段理解。前四个代表从原图的哪个区域取像素sx、sy 是原图的裁剪起点坐标sw、sh 是裁剪的宽高。后四个代表把这个区域画到画布的哪个位置dx、dy 是画布上的目标坐标dw、dh 是绘制出来的宽高。在九宫格拼图里我们把一张图按 3 行 3 列切分每块的源区域就是从原图左上角开始按行和列偏移计算出来的小矩形。假设原图宽 300、高 300那么每个碎片宽度和高度都是 100。第 row 行、第 col 列的碎片源区域就是从 (col * 100, row * 100) 开始宽高 100 的区域。这块理解透彻了后面导出碎片就顺理成章。生成 9 个碎片的核心循环我写过很多次代码不长但很关键直接贴出来function generatePieces(canvas, ctx, img, gridSize, pieceSize) { let pieces [] for (let row 0; row gridSize; row) { for (let col 0; col gridSize; col) { ctx.clearRect(0, 0, pieceSize, pieceSize) ctx.drawImage( img, // 原图对象 col * pieceSize, // 源区域 x row * pieceSize, // 源区域 y pieceSize, // 源区域宽 pieceSize, // 源区域高 0, 0, // 画到 canvas 的起点 pieceSize, // 画到 canvas 的宽度 pieceSize // 画到 canvas 的高度 ) // 这里把 canvas 当前内容导出成临时图片文件 pieces.push({ row: row, col: col }) } } return pieces }每次绘制前清空画布绘完一块就立即导出导出后再绘制下一块所以这块 canvas 每次只承载一个碎片。这里有个小细节ctx.clearRect 不是每次都必需的因为 drawImage 本身是全幅覆盖但为了保证极端情况下边缘像素不残留我习惯先清一下代价很小收益是排除了一个不确定因素。2.3 导出碎片wx.canvasToTempFilePath 的注意事项绘制完成只是第一步真正的难点在导出这个环节。微信小程序没有浏览器那种 toDataURL 方法只能用 wx.canvasToTempFilePath 把 canvas 内容导出为一张临时图片然后把生成的图片路径交给页面上的 view 去展示。使用 2D 接口时导出调用要放在 canvas 节点上有一点要注意Canvas 2D 接口下的导出要在 canvas 的 node 实例上调用。代码示例wx.canvasToTempFilePath({ canvas: canvas, // 2D canvas 实例 x: 0, y: 0, width: pieceSize, height: pieceSize, destWidth: pieceSize * 2, // 导出图片的宽建议 2 倍以上 destHeight: pieceSize * 2, // 导出图片的高 fileType: png, success(res) { // res.tempFilePath 就是这个小碎片的临时图片路径 }, fail(err) { console.error(导出失败, err) } })再说说清晰度的问题。如果原图是高清图而碎片区域在导出时只按原像素大小导在 Retina 屏幕上就会被拉伸得模糊。我的做法是把导出图的分辨率放大到原来的 2 倍destinationWidth 设为 pieceSize 的 2 倍这样展示在 view 上时图片源分辨率高于显示分辨率视觉上非常锐利。代价是临时文件体积变大但一个碎片也就几十 KB可以接受。导出的临时文件默认在小程序的临时目录里本次会话有效下次启动就没了所以不需要手动清理退出逻辑只要注意不要在同一个页面重复导出太多次把临时目录撑爆即可。3. 乱序算法与交换逻辑决定游戏能不能玩3.1 先理清数据模型正确位置和当前位置拼图的数据结构是核心。你需要让每一块碎片记住两个位置它在完整拼图中的正确位置以及它当前在画布上的实际位置。正确位置用 row 和 col 两个字段表示当前实际位置也用 row 和 col 表示。我习惯用长度为 gridSize 乘 gridSize 的数组保存碎片对象数组索引对应的就是它在画布上的实际显示位置。比如数组第 0 项表示画布左上角的碎片第 1 项表示右上角以此类推。碎片对象里存一个 correctIndex 字段表示它拼好之后应该落在哪个索引位置。这带来一个判断胜利的很自然的条件遍历数组只要每一项的 correctIndex 不等于当前索引就说明还没拼好。全部相等就是成功。不用额外维护状态逻辑非常干净。// 初始化有序碎片列表 function initBoard(pieces) { // pieces 是从 Canvas 导出的碎片路径列表长度 9 // 初始时每个碎片落在自己正确的位置 return pieces.map((path, index) ({ path: path, correctIndex: index, currentIndex: index })) }3.2 Fisher-Yates 洗牌算法还要保证可解打乱拼图不能直接随机交换因为有些排列状态永远无法通过交换还原。学过一点线性代数的朋友知道交换两个元素会改变排列的逆序数奇偶性不同就不可还原。普通玩家玩到死也拼不回原图体验很糟。最稳妥的方案是模拟逐步打乱从正确状态开始做 N 次合法的相邻交换相邻的含义是上下左右四个方向这样每一步都是可逆的最终状态一定可解。N 一般取 50 到 100 步打乱效果已经足够均匀。另外很多人写九宫格会留一个空格类似华容道那种玩法要求空格存在交换是把相邻碎片移入空格。但我的做法是无空格的点击交换或拖拽交换这种情况只要洗牌次数足够且按相邻交换天然可解。如果你做的是华容道式拼图那就额外需要计算逆序数可解性这里不多展开。function shuffleBoard(board, gridSize) { let matrices toMatrix(board, gridSize) let steps 60 for (let i 0; i steps; i) { // 随机找一个位置再找它上下左右的邻居 let row Math.floor(Math.random() * gridSize) let col Math.floor(Math.random() * gridSize) let neighbors [] if (row 0) neighbors.push([row - 1, col]) if (row gridSize - 1) neighbors.push([row 1, col]) if (col 0) neighbors.push([row, col - 1]) if (col gridSize - 1) neighbors.push([row, col 1]) let target neighbors[Math.floor(Math.random() * neighbors.length)] swapMatrix(matrices, row, col, target[0], target[1]) } return toList(matrices) }3.3 点击交换与拖拽交换手势交互要分开设计九宫格拼图有两种主流交互方式我这里都说说。点击交换是最简单的交互适合手机上玩家手指粗、对不准的场景玩家先点击一个碎片该碎片高亮再点击另一个碎片两个交换位置。注意点击的时候要判断两次点击不是同一个碎片还要考虑当前是否已经有一个选中的碎片。拖拽交换则更直观适合追求操作爽感的玩法。在触摸事件里判断手指滑动的方向是向左、向右、向上还是向下然后把当前碎片和相邻位置交换。这里不建议做成拖到哪个位置就交换到哪个位置因为小屏幕上手指会遮挡碎片玩家根本看不到自己拖到哪了。滑动方向判定反而更符合直觉。我最后做主交互用的是滑动方向交换再用点击作为辅助。滑动判定用 touchstart 和 touchend 两个坐标的差值判断水平位移大于垂直位移就判定为左右滑动反之是上下滑动同时设置一个最短滑动距离阈值 30px防止误触。onTouchStart(e) { this.touchStartX e.touches[0].clientX this.touchStartY e.touches[0].clientY }, onTouchEnd(e) { let dx e.changedTouches[0].clientX - this.touchStartX let dy e.changedTouches[0].clientY - this.touchStartY let threshold 30 if (Math.abs(dx) threshold Math.abs(dy) threshold) { // 视为点击处理点击选中逻辑 this.handleTap(e) } else if (Math.abs(dx) Math.abs(dy)) { this.swapTo(dx 0 ? right : left) } else { this.swapTo(dy 0 ? down : up) } }这块有一个值得注意的细节touchstart 到 touchend 之间手指应该限制在哪个碎片范围内开始才算数。如果玩家从画布空白处开始滑动不应该触发任何交换。所以要在 touchstart 里先判断触摸点落在哪个碎片上记录那个碎片的 currentIndex在 touchend 时才能确定交换基准。4. 完整代码实现从 WXML 到 JS 落地可运行4.1 页面结构一个Canvas加九宫格碎片Canvas 节点在页面里是隐藏的或者说不在可视区域。我的习惯是让它绝对定位到屏幕外或者设置成透明度 0。因为它的作用只是切图展示碎片的是下面的 view 网格。WXML 结构我拆成三块顶部标题栏、Canvas 切图区视觉隐藏、拼图操作区、底部按钮。拼图操作区由 9 个 view 组成通过 flex-wrap 布局排成 3x3。每个碎片的背景图通过内联 style 设置 backgroundImage叠加 background-size: 100% 100% 防止拉伸变形。view classpage !-- 隐藏的 canvas 切图区 -- canvas type2d idpuzzleCanvas classhidden-canvas/canvas !-- 拼图操作区 -- view classboard view classpiece wx:for{{board}} wx:keyindex stylebackground-image: url({{item.path}}); width: {{pieceWidth}}px; height: {{pieceHeight}}px; >.hidden-canvas { position: fixed; left: -9999px; top: -9999px; width: 300px; height: 300px; } .board { display: flex; flex-wrap: wrap; width: 300px; height: 300px; position: relative; margin: 30px auto; } .piece { box-sizing: border-box; border: 1rpx solid rgba(0, 0, 0, 0.1); display: inline-block; background-color: #eee; background-size: 100% 100%; background-repeat: no-repeat; transition: transform 0.2s ease; } .selected { box-shadow: 0 0 0 4rpx rgba(66, 133, 244, 0.7); z-index: 10; position: relative; }4.3 页面逻辑加载图片、切图、洗牌、胜负判定页面逻辑层我按模块拆了几个函数核心流程在 onReady 里启动。onReady 时先通过 SelectorQuery 拿到 Canvas 节点然后调用 initGame 方法initGame 里包含 loadImage、generatePieces、shuffleBoard 和 renderBoard 四步。loadImage 要注意本地图片和网络图片的差异。本地静态资源路径直接传给 Image 对象就能用网络图片必须走 wx.getImageInfo 或者 wx.downloadFile 把远程图片下载到本地临时目录拿到临时路径之后才能传给 Image 对象绘制到 canvas 上。这个规则很多人第一次都猜不到会直接拿 https 链接去 new Image()结果画布一片空白。拿 image 对象也有讲究我的写法是通过 canvas.createImage() 创建图片对象这是 Canvas 2D 接口的标准方式而不是 new Image()后者在小程序运行环境里没有这个全局构造器。onReady() { let query wx.createSelectorQuery() query.select(#puzzleCanvas) .fields({ node: true, size: true }) .exec((res) { this.canvas res[0].node this.ctx this.canvas.getContext(2d) this.initGame(./images/demo.jpg) }) }, loadImage(src) { return new Promise((resolve, reject) { let img this.canvas.createImage() img.onload () resolve(img) img.onerror (err) reject(err) img.src src }) }, async initGame(src) { let img await this.loadImage(src) // 计算碎片尺寸取画布尺寸的三分之一 let canvasWidth this.canvas.width let canvasHeight this.canvas.height this.pieceSize canvasWidth / 3 // 假设画布是正方形 this.clearAndCut(img) // 切图完成后 board 里的碎片图片都是临时路径 this.shuffleBoard() this.renderBoard() }胜负判断我写成一个 checkWin 方法在每次 swapTo 完成之后调用。如果发现数组每一项 currentIndex 都等于正确索引就弹一个提示显示成功动画或跳转。checkWin() { let win this.board.every((item, index) item.correctIndex index) if (win) { wx.showModal({ title: 恭喜, content: 拼图已完成, showCancel: false }) } }4.4 让碎片动起来交换动画的实现细节动画实现是拼图体验的重要一环。由于碎片是普通 view我用 transform 的 translate 值来控制位置。每个碎片在网格中的基准位置用 left col * pieceWidth、top row * pieceHeight交换时把两个碎片的位移目标互换触发 transform 过渡就有平移动画。听起来不复杂实际做有个细节transform 平移是在元素自身定位基础之上的所以两个碎片交换视觉位置后它们的 DOM 顺序并没有变化但视觉位置交换了。此时必须同步更新数据里的 currentIndex 和每个碎片的 translate 值否则动画结束但数据没跟上touch 事件对应的碎片就和画面上看到的不一致。这块我在开发时漏过一次动画结束后点击某一个碎片结果高亮的是另一个碎片排查了很久才意识到数据同步问题。给碎片指定 translate 的代码大致是这样syncPiecePositions() { this.board.forEach((item, index) { let row Math.floor(index / 3) let col index % 3 // 设置每个碎片的 translateX 和 translateY this.setData({ [board[${index}].x]: row * this.pieceWidth, [board[${index}].y]: col * this.pieceHeight }) }) }由于小程序的 setData 支持指定路径更新用 board[index].x 这种方式更新单个字段比整体 setData 整个数组性能要好得多。9 个碎片还好如果做 4x4 甚至 5x5 的网格你就能感受到性能差异了。5. 真机调试时最容易踩的六个坑5.1 画布白屏图片画不出来九宫格拼图新手最常见的问题是图片加载出来了但 canvas 一片白。排查顺序我建议这样走第一步确认有没有在 onReady 之后才初始化 canvas节点没渲染完就绘制必然白屏。第二步确认图片路径是本地路径而不是网络 URL网络图必须先 downloadFile。第三步确认 canvas 的节点宽高不为 0如果 CSS 里写成 100% 而父容器没给高度canvas 会变成 0 高。第四步确认绘制代码有没有报错打开 vConsole 看 console 有没有异常。这几个问题里第二点出现的概率最高。尤其注意在模拟器里直接传 https 链接可能正常但真机上一律不认必须 wx.getImageInfo 转换这是微信小程序的机制不是模拟器差异。5.2 真机上触摸偏移点到不是想点的碎片微信小程序里 touch 事件的 clientX 和 clientY 是相对可视区域的而碎片的位置可能是相对某个容器的。如果页面没有滚动容器恰好也贴着屏幕左上角这俩数值差距不大。但如果页面有滚动或者容器有 margin直接用 clientX、clientY 去换算就偏了。稳妥的做法是用 createSelectorQuery 获取 board 容器的 boundingClientRect得到容器左上角相对屏幕的偏移 rect.left 和 rect.top然后拿触摸点坐标减去这个偏移得到相对容器的坐标再计算落在哪个碎片上。这一步闲时看似多余真机上页面一旦有弹窗、滚动、刘海屏安全区全都可能引发偏移。算一次开销很小但能避免大量定位不准的 bug。getBoardRect() { return new Promise((resolve) { let query wx.createSelectorQuery() query.select(.board).boundingClientRect((rect) { resolve(rect) }).exec() }) }另外注意 iPhone 刘海屏的安全区适配。如果 board 区域上方有自定义导航栏页面内容被刘海遮挡触摸事件的 clientY 本身是包含安全区高度在内的但视觉上 board 的 top 已经避开了安全区如果不做换算还是会偏。我统一用 boundingClientRect 的 rect 作为基准就是为了一次性处理这些情况。5.3 图片碎片模糊或比例不对模糊问题多半是导出的图片分辨率不够解决办法我前面提到了把 destWidth 和 destHeight 设为碎片的 2 倍。比例不对一般是 background-size 设置的问题碎片 view 宽高不一定是正方形如果背景图强制 100% 100%会变形如果 100% auto又会露白边。碎片本身是正方形背景图尺寸也是正方形所以 background-size: 100% 100% 在这种场景下正好但如果原图不是正方形需要先等比例裁剪成正方形再切。我做切图前会先统一处理原图把原图裁成正方形再缩放为 canvasSize 乘 canvasSize。这个过程可以在内存中用 Canvas 完成在原图上画一个正方形区域绘制到目标画布上实现居中裁剪改为正方形。九宫格拼图默认所有块形状相同正方形画布最合理。5.4 游戏不可解这个坑最容易出现得无声无息。如果你直接随机交换数组元素来打乱很可能生成的状态在交换规则下是无解的。玩家在页面上一顿操作最后剩下两个碎片永远对不上体验直接归零。解决方式前面已经提到从正确顺序出发做有限次相邻交换保证可解。还有一种情况是玩家自己拼着拼着发现卡住了其实不是代码问题是用户手滑交换错了一个位置。这种可以在界面上加一个撤销按钮记录历史操作栈点撤销回退一步。我第一次上线后就发现玩家强烈需要一个撤销按钮加完之后差评少了很多。5.5 连续点击速度过快导致动画错乱快速点击碎片时两个动画会叠加transform 会被中间态打断视觉上碎片可能飞到错误位置。处理办法是加一个 isAnimating 锁动画执行期间忽略新的触摸事件。在动画结束后通过 transitionend 事件或者 setTimeout释放锁。这里用 setTimeout 比 transitionend 事件更靠谱因为部分安卓机上 transitionend 触发不稳定。设置 0.25 秒再解锁跟 CSS 的 0.2s transition 时长稍微留一点余量刚好避开临界抖动。5.6 临时文件积压导致内存占用过高每次切图会生成 9 个临时文件如果玩家频繁换图片临时目录会积累大量文件。虽然系统会清理但在同一种会话里短时间生成上百个文件还是可能触发性能问题。我的做法是在每次开始切图前先记录旧临时文件路径新图片切完后调用 wx.removeSavedFile 逐个清理旧文件或者至少保证临时目录里只保留最近一次切图产生的 9 个文件。这个优化在运营活动页很有必要一个页面可能被多个用户反复使用长期不清理最终用户手机存储会吃紧。6. 扩展思路从九宫格到更多玩法6.1 滑块拼图的实现思路如果你看完九宫格想再进一步滑块验证码风格拼图是一个不错的改造方向。原理上也是 Canvas 切图不过只切一块带形状的碎片比如圆形或锯齿形然后把碎片放到一个可拖动的 view 上背景图留出对应的缺口。拖动的过程用 touchmove 更新碎片位置在 touchend 时判断碎片中心距离缺口中心的差距小于阈值比如 15px就判定拼合成功。滑块拼图的重点在碎片形状的处理。锯齿形缺口有两种方案一种是在 Canvas 上直接画小圆或锯齿路径填充成碎片形状再导出成透明底的 PNG另一种是用遮罩层遮住缺口区域视觉上有个缺口。透明底 PNG 的下载体积和内存占用会更大但效果最自然。6.2 支持用户自定义图片的实现方案让玩家从相册选择图片来拼图玩法会丰富很多。接入 wx.chooseMedia 获取本地图片拿到临时路径后再走一遍 loadImage 和切图流程即可。要注意的是相册图片尺寸可能特别大比如 4000 乘 3000 像素的高清图直接画进 300 乘 300 的画布不仅浪费内存绘制还慢。我用 Canvas 先做一个等比缩放到 600 乘 600 左右的中间图再做切图性能会好很多。另外用户相册里的图尺寸比例五花八门等比例缩小成正方形之前需要裁剪。我再次用 Canvas 做了居中裁剪取原图中心的正方形区域忽略多余的部分。这是最常见的需求做产品时要提醒用户图片会被居中裁切不然用户会反馈图片变形。6.3 游戏化包装计时、步数、排行榜拼图功能一旦想用作裂变活动纯粹拼图是不够的。加上计时和步数统计用两个变量记录游戏开始时间和每一步交换胜利时展示耗时和步数并提供一个炫耀成绩的分享卡片能明显提升二次传播率。实现上就是维护 startTime 和 moveCount在 restartGame 时重置在 swapTo 成功时自增。分享卡片用 onShareAppMessage 配置把成绩参数拼到查询参数里好友点进来可以挑战该成绩。我给活动方做过一版九宫格比拼单人拼图平均留存提升了 40% 多主要是成绩可比较用户有重复挑战的动力。6.4 自定义难度3x3、4x4、5x5九宫格是 3 乘 3想增加难度可以做 4 乘 4 甚至 5 乘 5。核心代码几乎不用改gridSize 从 3 变成 4 或 5碎片数量相应增加。但要注意碎片尺寸减小之后触摸误触率会上升4x4 建议碎片宽高不低于 80px5x5 最好不低于 50px不然人手操作精度不够玩家挫败感很强。屏幕本身就那么大碎片尺寸和难度之间的平衡需要实际测试调整。我在开发里把 gridSize 做成了配置项游戏加载时根据屏幕宽度计算合适的 gridSize屏幕小就选 3x3大屏可以 4x4。通关后还可以给一个下一难度的入口这样一个玩法能顶三个难度档位。最后再说一点个人体会。拼图这种功能代码本身并不复杂真正花时间的地方全在边界情况和交互手感上。切图导出、乱序可解、触摸坐标换算、数据与视觉同步这几个点只要有一个没想清楚真机上就会翻车。如果你也是第一次在小程序里做拼图强烈建议先在开发者工具里把九宫格跑通再用真机过一遍交互重点测触摸偏移和动画锁这两个点。等这两个地方稳了这个功能后续再扩展成任何形式的拼图玩法都能顺手很多。