数字华容道这个项目,我在不同阶段反复做过好几遍。最早是给一个做益智游戏合集的朋友帮忙,后来自己带新人练手也拿它当入门题。表面上看它就是个滑块拼图,规则简单到一句话能说完,但真正动手做进小程序里,从棋盘布局、手势交互、状态管理到动画节奏,每一块都有不少讲究。这次复刻系列做到第二篇,我打算把数字华容道从头到尾拆一遍,把那些文档里不会写、但实际做的时候一定会撞上的细节都摊开讲。
这篇文章面向的是已经能写基本小程序页面、想找一个完整项目练手的中级开发者,也适合做过类似拼图但总觉得手感不对、动画卡顿、状态混乱的朋友。我会从整体设计思路讲起,然后逐层拆解棋盘数据结构、滑动判定逻辑、动画实现、打乱算法、计时与步数统计,最后把我踩过的坑整理成一份排查清单。全程按小程序原生技术栈来写,不依赖第三方游戏引擎,代码可以直接对照着改。
1. 项目整体设计与技术选型拆解
1.1 为什么选数字华容道作为复刻练手项目
数字华容道看起来简单,实际上它同时覆盖了前端开发里几个很核心的能力:二维数组的状态管理、手势事件的坐标换算、CSS 动画与数据更新的时序配合、随机算法的正确性验证。它不像做一个列表页那样线性,也不像做一个复杂表单那样堆字段,它是一个状态驱动、交互密集、对时序敏感的小型系统。你把它做扎实了,后面做任何拖拽类、棋盘类、消除类的交互都会顺手很多。
从产品角度看,数字华容道的规则天然自洽,用户不需要教程就能上手,这意味着你的开发精力可以全部放在手感和细节上,而不是花在解释玩法上。这一点对练手项目特别重要,因为规则越简单,你越能逼自己去打磨那些真正影响体验的东西,比如滑动跟手程度、动画时长、错误操作的反馈。
我选小程序而不是 H5 或原生 App,是因为小程序的渲染层和逻辑层分离模型会逼着你把状态和视图的关系想清楚。你不能像操作 DOM 那样随手改一个元素,所有视图变化都得通过 setData 走数据驱动。这个约束一开始让人别扭,但做多了你会发现它反而帮你建立了更干净的状态管理习惯。
1.2 技术栈与目录结构规划
整个项目用小程序原生框架,不引入游戏引擎。原因很直接:数字华容道的渲染量很小,一个 4x4 的棋盘最多 16 个格子,用原生 WXML 加 CSS 完全够用,引入引擎反而增加包体积和调试成本。动画部分优先用 CSS transition,因为它在渲染层执行,不占用逻辑层的计算资源,滑动时的流畅度比用 JS 逐帧改位置要好得多。
目录结构我习惯这样组织:
pages/ game/ game.js 逻辑:状态、滑动判定、计时、打乱 game.wxml 结构:棋盘容器、格子、控制栏 game.wxss 样式:布局、过渡、主题 game.json 页面配置 utils/ puzzle.js 纯函数:生成、打乱、可解性校验、胜利判定把纯逻辑抽到 utils 里是我强烈建议的做法。棋盘生成、打乱、可解性判断这些函数不依赖页面上下文,抽出来之后可以单独写测试,也可以在调试时直接在控制台调用验证。很多人把所有逻辑堆在 page 的 js 里,结果一个文件几百行,改一个滑动判定要翻半天,还容易误伤计时逻辑。
1.3 核心需求的边界划定
动手前先把需求边界划清楚,能省掉大量返工。这个项目我给自己定的范围是:4x4 棋盘、点击与滑动两种操作方式、步数统计、计时、重新开始、胜利判定与提示。明确不做的是:多难度切换、排行榜、音效、皮肤系统。不是说这些不能做,而是第一版先把核心闭环跑通,扩展项留到结构稳定之后再加,否则很容易在皮肤系统上耗掉大半时间,核心手感却没调好。
提示:需求边界一定要在写第一行代码前定下来。数字华容道最容易失控的地方就是"顺手加个功能",加着加着状态就乱了,最后连滑动判定都调不明白。
2. 棋盘数据结构与核心逻辑设计
2.1 用一维数组还是二维数组
这是第一个要做的选择。二维数组board[row][col]直观,符合人对棋盘的认知,但小程序 setData 对深层路径的更新支持虽然可以,写起来路径字符串会比较长,而且做相邻判定时要处理行列边界。一维数组board[index]配合index = row * size + col的换算,在滑动判定和 setData 更新上都更简洁。
我最终选一维数组,核心原因是滑动判定本质上是"两个相邻格子交换值",而相邻关系在一维里就是索引差 1(同行左右)或差 size(同列上下),判断起来非常干净。二维数组反而要在每次判定时先算行列再判断是否越界,代码更长也更容易出错。
// 一维索引与行列互转 const size = 4; const toIndex = (row, col) => row * size + col; const toRow = (index) => Math.floor(index / size); const toCol = (index) => index % size;棋盘里用 0 表示空格,其余数字 1 到 15 表示对应方块。用 0 而不是 null 或空字符串,是因为 0 在数值比较和索引运算里不会出岔子,渲染时判断value === 0就渲染成透明占位即可。
2.2 滑动判定的核心算法
滑动判定是整个项目的心脏。用户点击某个方块时,要判断它能否移动到空格位置。规则只有一条:方块与空格在同一行且列相邻,或同一列且行相邻,才能移动。用一维索引表达就是:
function canMove(blankIndex, targetIndex, size) { const blankRow = Math.floor(blankIndex / size); const blankCol = blankIndex % size; const targetRow = Math.floor(targetIndex / size); const targetCol = targetIndex % size; const sameRow = blankRow === targetRow && Math.abs(blankCol - targetCol) === 1; const sameCol = blankCol === targetCol && Math.abs(blankRow - targetRow) === 1; return sameRow || sameCol; }这里有个容易忽略的细节:不能只用Math.abs(blankIndex - targetIndex) === 1 || === size来判断。因为一维索引差 1 在行首行尾会跨越行边界,比如索引 3 和索引 4 差 1,但它们分别是第一行最后一格和第二行第一格,实际并不相邻。所以必须换算成行列再判断,这一步省不得。
滑动执行就是交换两个位置的值,然后更新空格索引:
function move(board, blankIndex, targetIndex) { const next = board.slice(); [next[blankIndex], next[targetIndex]] = [next[targetIndex], next[blankIndex]]; return { board: next, blankIndex: targetIndex }; }用slice()复制而不是原地修改,是为了配合 setData 的数据驱动模型。原地改数组再 setData 同一个引用,小程序有时不会触发更新,这个坑我踩过,排查了半天才发现是引用没变。
2.3 打乱算法与可解性校验
打乱不能简单随机排列,因为 4x4 数字华容道有一半的随机排列是无解的。判断可解性的经典方法是计算逆序数:把棋盘按行展开成一维序列(去掉空格 0),统计逆序对数量。对于偶数阶棋盘,逆序数为偶数时可解,奇数时不可解。
function countInversions(board) { const seq = board.filter(v => v !== 0); let count = 0; for (let i = 0; i < seq.length; i++) { for (let j = i + 1; j < seq.length; j++) { if (seq[i] > seq[j]) count++; } } return count; }但更稳妥、也更符合真实操作的做法是:从已解状态出发,反向执行大量随机合法移动。这样生成的棋盘一定是可解的,因为每一步都是合法移动的逆过程。我一般做 200 到 500 次随机移动,次数太少打乱不充分,太多没必要且增加初始化耗时。
function shuffle(board, blankIndex, size, steps = 300) { let current = board.slice(); let blank = blankIndex; let lastBlank = -1; for (let i = 0; i < steps; i++) { const neighbors = getNeighbors(blank, size).filter(n => n !== lastBlank); const pick = neighbors[Math.floor(Math.random() * neighbors.length)]; [current[blank], current[pick]] = [current[pick], current[blank]]; lastBlank = blank; blank = pick; } return { board: current, blankIndex: blank }; }lastBlank的作用是避免刚移动过的方块立刻被移回去,否则打乱效率会明显下降,出现大量无效往返。这个细节很小,但实测下来能让打乱结果更均匀。
2.4 胜利判定的实现
胜利条件就是棋盘按顺序排列且空格在最后一格。判定时直接比较数组即可:
function isSolved(board) { for (let i = 0; i < board.length - 1; i++) { if (board[i] !== i + 1) return false; } return board[board.length - 1] === 0; }每次移动后调用一次,成本可以忽略。有人喜欢在渲染时顺便判断,我不建议,因为判定逻辑和渲染逻辑混在一起后,后面加动画或提示会很难拆。
3. 交互实现与动画手感调优
3.1 点击与滑动两种操作方式的取舍
点击操作实现简单:给每个格子绑定 tap 事件,拿到索引后走 canMove 判定。滑动操作复杂一些,需要监听 touchstart、touchmove、touchend,根据位移方向判断用户想移动哪个方块。我的做法是两种都支持,但以点击为主、滑动为辅,因为点击的容错率更高,滑动在格子较小的时候容易误判方向。
滑动判定的思路是:记录 touchstart 的坐标,touchend 时算位移,如果水平位移大于垂直位移且超过阈值,就认为是左右滑,反之是上下滑。然后根据滑动方向找到空格相邻的那个方块执行移动。这里的关键是阈值不能太小,否则轻微抖动就触发;也不能太大,否则用户滑了没反应。我实测下来 30 到 40 像素比较合适,具体还要结合格子尺寸调整。
const THRESHOLD = 35; function resolveSwipe(dx, dy) { if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return null; if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left'; return dy > 0 ? 'down' : 'up'; }3.2 用 CSS transition 实现跟手滑动
动画这块我走过弯路。最早用 JS 定时器逐帧改位置,结果低端机上明显掉帧,而且和 setData 的异步更新打架,方块会出现瞬移。后来改成 CSS transition,把每个方块的位置用transform: translate表达,数据更新后浏览器自动补间,流畅度立刻上了一个台阶。
具体做法是每个格子绝对定位,left 和 top 由索引换算,但不用 left/top 做动画,而是用 transform。因为 transform 走合成层,不触发重排,性能更好。切换位置时只改 transform 的值,配合transition: transform 0.15s ease-out,就能得到顺滑的滑动效果。
.tile { position: absolute; width: 25%; height: 25%; transition: transform 0.15s ease-out; will-change: transform; }动画时长 0.15 秒是我反复调出来的。太短(0.08 秒)显得生硬,像瞬移;太长(0.3 秒)连续快速操作时会积压,手感发黏。0.15 秒左右既有明确的移动感,又不会拖慢连续操作。ease-out让起步快、收尾缓,符合物理直觉。
3.3 数据更新与动画的时序配合
这里有个必须讲清楚的坑:setData 是异步的,如果你在 setData 回调之前就去读 DOM 或做下一步操作,很可能拿到旧状态。数字华容道里连续快速点击时,如果不等上一次动画结束就处理下一次,容易出现两个方块同时移动、位置错乱。
我的处理方式是维护一个animating标志位,移动开始时置 true,动画时长结束后置 false,期间忽略新的移动请求。这样虽然牺牲了一点极限操作速度,但换来了状态的绝对稳定。对于益智游戏来说,稳定比极限响应更重要,用户不会因为你少响应了一次 0.1 秒内的连点而抱怨,但一定会因为方块错位而骂人。
onTileTap(e) { if (this.data.animating || this.data.solved) return; const target = e.currentTarget.dataset.index; if (!canMove(this.data.blankIndex, target, this.data.size)) return; this.setData({ animating: true }); const result = move(this.data.board, this.data.blankIndex, target); this.setData({ board: result.board, blankIndex: result.blankIndex, steps: this.data.steps + 1 }, () => { setTimeout(() => { this.setData({ animating: false }); this.checkWin(); }, 150); }); }setData 的回调里再延时 150 毫秒解锁,这个 150 要和 CSS transition 的时长严格对齐,否则会出现动画还没结束就允许下一次操作的情况。我建议把动画时长抽成一个常量,JS 和 CSS 都引用同一个值,改的时候只改一处。
3.4 视觉层次与可读性处理
数字华容道的视觉重点在于让用户一眼看清哪个是空格、哪些能移动。我的处理是:空格完全透明只留底色,数字方块用浅色背景加深色数字,字号随格子尺寸自适应。能移动的方块可以加一点微妙的阴影或边框提示,但不能太抢眼,否则整个棋盘会显得花。
数字的字体建议用等宽或接近等宽的字体,因为 1 和 15 宽度差异大,用比例字体时方块内数字的视觉重心会飘。字号用vw或根据容器宽度算,保证不同屏幕下比例一致。这些细节单看都不起眼,但堆在一起就决定了成品是"能用"还是"好用"。
4. 计时、步数与状态管理的工程细节
4.1 计时器的正确启停方式
计时看起来简单,实际上是最容易出 bug 的地方。常见错误是页面隐藏后计时器还在跑,或者重新开始时旧计时器没清掉导致多个计时器叠加、秒数跳变。我的做法是:计时器句柄存在页面实例上,开始游戏时先清一次再新建,页面 onHide 时暂停,onShow 时恢复,onUnload 时清除。
startTimer() { this.stopTimer(); this.timer = setInterval(() => { this.setData({ elapsed: this.data.elapsed + 1 }); }, 1000); }, stopTimer() { if (this.timer) { clearInterval(this.timer); this.timer = null; } }用 setInterval 每秒加一而不是记录开始时间戳再算差值,是因为后者在页面隐藏再恢复时会出现时间跳跃。当然前者也有精度问题,长时间运行会累积误差,但对一局几分钟的拼图来说完全可以接受。如果你要做排行榜级别的精确计时,那就得用时间戳差值,并在暂停时把已用时长存下来。
4.2 步数统计的边界情况
步数统计本身简单,每次有效移动加一。但有几个边界要处理:无效移动(点了个不能动的方块)不能计步;重新开始要清零;胜利后不能再计步。这些看起来是常识,但实际写的时候很容易漏掉无效移动的判断,导致用户乱点也涨步数,体验很差。
我习惯把"是否有效移动"的判断放在最前面,无效直接 return,后面的计步、动画、胜利判定都不会执行。这样逻辑上只有一个入口,不会出现某个分支忘了计步或忘了判定胜利的情况。
4.3 页面生命周期与状态持久化
小程序页面会被切走再切回,如果用户切到别的页面再回来,棋盘状态应该保留,计时应该继续。这就要求把棋盘、步数、已用时长都放在 data 里,而不是存在局部变量里。同时 onHide 时暂停计时,onShow 时如果游戏未结束就恢复计时。
如果要做"退出后回来还能继续"的持久化,可以用小程序的本地存储,在每次有效移动后把棋盘和步数写进去,页面加载时读出来恢复。但要注意版本兼容,如果后续改了棋盘尺寸或数据结构,旧存档要能识别并丢弃,否则会读出脏数据导致渲染错乱。我一般会在存档里带一个版本号字段,读取时校验版本,不匹配就重新开局。
4.4 胜利提示与交互收尾
胜利后要立刻停止计时、锁定棋盘、给出明确反馈。反馈可以是弹层、可以是棋盘高亮,但一定要让用户明确知道"我赢了",而不是靠数字排好了自己发现。我的做法是胜利后棋盘整体加一个轻微的缩放或高亮动画,同时弹出一个简洁的结果层,显示步数和用时,提供"再来一局"按钮。
这里有个体验细节:胜利动画不要和最后一次滑动的动画抢时间。应该等最后一次滑动动画结束(也就是 animating 解锁之后)再触发胜利提示,否则用户会看到方块还在动,结果层就盖上来,很突兀。这也是为什么我把 checkWin 放在动画结束的回调里,而不是移动后立刻判断。
5. 常见问题排查与实操避坑清单
5.1 方块错位与状态不同步
这是最高频的问题,表现是滑动后方块位置和数字对不上,或者两个方块叠在一起。根因几乎都是 setData 异步和动画时序没对齐。排查顺序是:先确认 animating 标志位是否在动画结束前就被重置,再确认 CSS transition 时长和 JS 里的延时是否一致,最后检查是否有地方直接修改了 board 数组而没有走 setData。
我整理了一份速查表,遇到问题按这个顺序过一遍,基本都能定位:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 方块位置错乱 | 直接改数组未 setData | 检查是否用了 slice 复制 |
| 连续点击后叠层 | animating 未生效 | 确认标志位在回调里重置 |
| 动画卡顿掉帧 | 用 left/top 做动画 | 改用 transform |
| 滑动无反应 | 阈值过大或方向判断错 | 打印 dx dy 实际值 |
| 打乱后无解 | 用了纯随机排列 | 改为反向随机移动 |
5.2 打乱后出现无解棋盘的排查
如果你用了纯随机排列生成棋盘,大约一半的概率是无解的,用户怎么拼都拼不出来。判断方法是算逆序数奇偶性。但更省事的做法是根本不用随机排列,直接用反向随机移动生成,从源头杜绝无解。如果你已经用了随机排列,加一个可解性校验,不可解就重新生成,虽然理论上可能循环多次,但实际一两次就能出可解盘。
5.3 低端机上的性能问题
低端机上最容易出问题的是动画和频繁 setData。优化方向有三个:一是减少 setData 的数据量,只传变化的字段而不是整个 data;二是动画全部交给 CSS,JS 不参与逐帧计算;三是避免在 touchmove 里频繁 setData,滑动判定只在 touchend 做一次。我实测过,把 touchmove 里的实时反馈去掉之后,低端机的滑动流畅度提升非常明显。
注意:不要在 touchmove 里做 setData。触摸移动事件触发频率很高,每次都 setData 会让逻辑层和渲染层的通信队列堆积,直接表现为卡顿。滑动方向判定放到 touchend 一次性完成即可。
5.4 我踩过的几个具体坑
第一个坑是行首行尾相邻误判。前面提过,一维索引差 1 不代表相邻,我最早图省事用了索引差判断,结果第一行最后一格能和第二行第一格互换,棋盘直接乱掉。后来老老实实换算行列才解决。
第二个坑是计时器叠加。重新开始时我只重置了秒数,没清旧计时器,结果新计时器建起来后两个一起跑,秒数两倍速增长。修复就是在任何新建计时器之前先 stopTimer。
第三个坑是胜利判定时机。最早我在移动后立刻判定,结果最后一次滑动动画还没结束,胜利弹层就出来了,视觉上很割裂。改成动画回调里判定之后就顺了。
第四个坑是存档版本。早期没加版本号,后来改了棋盘结构,老用户读旧存档直接白屏。加了版本校验后,不匹配就静默重开,问题消失。
5.5 手感调优的经验参数
最后分享一组我反复调出来的参数,可以直接作为起点:动画时长 150 毫秒,缓动 ease-out,滑动判定阈值 35 像素,打乱步数 300 次,计时精度 1 秒。这些不是绝对值,要结合你的格子尺寸和屏幕密度微调,但作为初始值能让你少走不少弯路。调参的时候一次只改一个,改完真机跑几局感受一下,别在模拟器上定参数,模拟器的触摸和渲染跟真机差别不小。
真机测试这块我要多嘴一句:一定要在至少一台中低端安卓机上跑。小程序在高端机上什么写法都流畅,一到中低端机,setData 频率、动画方式、图片资源的问题全暴露出来。数字华容道这种交互密集的项目,真机手感是唯一标准,模拟器上顺不代表真机顺。
6. 可扩展方向与结构预留
6.1 难度切换的接入方式
第一版固定 4x4,但结构上要预留难度切换的空间。核心是把 size 做成变量而不是写死 4,棋盘生成、索引换算、相邻判定全部基于 size 计算。这样加 3x3 和 5x5 时,只需要改一个配置,逻辑层几乎不用动。5x5 的格子更小,滑动阈值和字号要相应调整,这些通过配置对象传入即可。
6.2 排行榜与数据上报的预留
如果后续要加排行榜,需要在胜利时上报步数和用时。结构上可以在胜利回调里留一个上报函数的调用点,第一版实现为空函数,后续填充即可。这样不会因为后期加功能而大改胜利逻辑。数据字段建议一开始就定好:棋盘尺寸、步数、用时、完成时间戳,避免后期补字段导致历史数据不可比。
6.3 主题与皮肤的扩展思路
皮肤系统的关键是不要把颜色写死在 WXSS 里,而是通过 CSS 变量或内联样式注入。棋盘背景、方块背景、数字颜色、空格样式都抽成变量,切换主题时只改变量值。这样加皮肤不用改结构,只加一套变量配置。我一般用页面根节点的 style 绑定一个主题对象,所有颜色从对象里取,扩展起来很干净。
这套结构做完之后,数字华容道就不只是一个练手 demo 了,而是一个可以持续加功能的小型游戏框架。我在实际项目里就是按这个思路先做核心闭环,再逐步加难度、加皮肤、加统计,每一步都不会推翻前面的结构。真正决定一个练手项目价值的,从来不是功能多少,而是结构能不能撑住后续的扩展。数字华容道这个题目小,但把状态、交互、动画、时序这几件事揉在一起,正好是练结构感的好材料。