ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序数独开发实战:求解器、交互与存档全解析

微信小程序数独开发实战:求解器、交互与存档全解析 简介这是一份供微信小程序入门者参考的数独游戏完整源码覆盖移动端游戏类应用的界面搭建、交互反馈与核心逻辑可直接导入微信开发者工具运行和学习。压缩包共53个文件仅975KB含10个js逻辑脚本、6个wxml页面结构、7个wxss样式、7个json配置以及12张jpg截图、10张png图片和1张gif演示图js、wxml、wxss按功能划分便于逐文件理解页面渲染与数据流。案例完整实现了9x9棋盘渲染、数字输入与合法性校验、新游戏生成、错误提示、本地存储续玩等功能并实际用到wx:for/wx:if条件渲染、bindtap事件绑定、setData状态更新、wx.setStorageSync数据持久化等小程序开发关键点适合边读代码边调试。已有1163人学习下载对想快速熟悉小程序项目结构、理解数独解法中数组与递归思路的开发者是一份很好的实战练手素材。1. 微信小程序数独源码 zip解压只是第一步能跑起来才是案例很多开发者下载了一个“微信小程序开发-游戏类-数独案例源码.zip”解压后拖进微信开发者工具发现不是报错就是白屏。问题通常不在代码本身而在于这个 zip 里装的是项目快照不是运行环境缺了node_modules、appid 还是别人的、基础库版本对不上、甚至工具版本和project.config.json里的compileType不匹配。真正能落地的微信小程序数独项目核心也不只是拿现成源码改改界面而是先把“题目从哪来、填进去怎么判、切后台怎么办”这三件事想清楚。这篇文章围绕一个可玩的微信小程序数独案例展开如何生成合格盘面、如何用原生小程序语法实现棋盘与交互、如何做计时与存档、如何在真机上验证行为。对刚接触微信小程序游戏开发的人这是一条完整可复现的路径对有经验的人重点看求解器设计、状态恢复和真机调试这几个容易翻车的点。2. 数独小程序的核心先写求解器再写题目生成器2.1 为什么生成数独题目前必须有一个「能数出解的求解器」数独的题目不是随便挖几个格子就能用的。一个合格的数独盘面必须满足“有解且解唯一”。如果一个小程序每次进入都生成多解题玩家填到最后会发现两个格子互换也能通关游戏直接失去意义。所以常见做法是先写一个求解器它既能填出完整终盘又能统计一个盘面有多少个解然后拿着这个求解器去“挖洞”生成题目。求解器在数独案例里有三个用途生成终盘时填格子、挖洞后做唯一解校验、玩家卡关时给提示。用同一个求解器处理这三件事代码的维护成本比三套逻辑低得多。我一般写回溯求解器配合位运算和最少候选优先MRV提速因为小程序端的 JS 引擎性能有限纯随机填的暴力回溯在困难盘面上可能卡到用户感知的级别。2.2 用回溯算法在 JavaScript 里生成完整终盘生成终盘的思路是从左到右、从上到下逐格填入数字每次尝试时检查行、列、宫是否冲突冲突就回退。为了不让每次打开都是同一个盘面第一行先用 Fisher-Yates 洗牌再从第二行开始回溯。下面这段代码可以直接放进小程序的utils/sudoku.js里用function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; } function generateSolution() { const board Array.from({ length: 9 }, () Array(9).fill(0)); // 第一行随机排列相当于对终盘做一次行内数字置换 const firstRow shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); board[0] firstRow.slice(); function isValid(row, col, num) { for (let i 0; i 9; i) { if (board[row][i] num) return false; if (board[i][col] num) return false; } const boxRow Math.floor(row / 3) * 3; const boxCol Math.floor(col / 3) * 3; for (let r boxRow; r boxRow 3; r) { for (let c boxCol; c boxCol 3; c) { if (board[r][c] num) return false; } } return true; } function solve(cellIndex) { if (cellIndex 81) return true; const row Math.floor(cellIndex / 9); const col cellIndex % 9; if (board[row][col] ! 0) return solve(cellIndex 1); const nums shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); for (const num of nums) { if (isValid(row, col, num)) { board[row][col] num; if (solve(cellIndex 1)) return true; board[row][col] 0; } } return false; } solve(0); return board; }这里的关键参数有三个第一行洗牌决定了终盘的“底子”isValid里的宫判断决定了 3×3 分块必须正确回溯时的随机顺序决定了每次生成的终盘不同但同质。注意solve每次尝试的数字都要经过洗牌否则生成器会倾向于在同一个位置填同一个数字导致生成的终盘虽然看起来不同但结构上高度相似。生成终盘的速度在开发者工具里很快但在低端安卓真机上如果关卡切得太频繁可能每秒生成多次建议生成一次后放进全局变量复用。另外一个容易被忽略的点generateSolution不应该在页面onLoad里同步执行并阻塞渲染数据量不大时可以接受但更好的做法是先渲染空盘再在setTimeout里生成后更新避免首屏白屏。2.3 按难度挖洞唯一解校验与提示数控制有了终盘下一步就是挖洞。挖洞的直观想法是“随机挑格子删掉删完检查还有没有解”但这里有个性能陷阱每删一个格子就要跑一次求解器的“数解”逻辑数解比求解慢得多因为找到第一个解不能停要跑完才能确定是否唯一。所以挖洞要控制节奏。下面这段代码实现了随机挖洞 唯一解校验countSolutions里用limit提前终止function countSolutions(board, limit 2) { const b board.map(row row.slice()); let count 0; function findEmpty() { for (let r 0; r 9; r) { for (let c 0; c 9; c) { if (b[r][c] 0) return [r, c]; } } return null; } function solve() { if (count limit) return; const empty findEmpty(); if (!empty) { count; return; } const [r, c] empty; for (let num 1; num 9; num) { if (isValidPlacement(b, r, c, num)) { b[r][c] num; solve(); if (count limit) return; b[r][c] 0; } } } solve(); return count; } function generatePuzzle(solution, holes) { const puzzle solution.map(row row.slice()); const positions []; for (let r 0; r 9; r) { for (let c 0; c 9; c) { positions.push(r * 9 c); } } shuffle(positions); let dug 0; for (const pos of positions) { if (dug holes) break; const r Math.floor(pos / 9); const c pos % 9; if (puzzle[r][c] 0) continue; const backup puzzle[r][c]; puzzle[r][c] 0; if (countSolutions(puzzle) 1) { dug; } else { puzzle[r][c] backup; } } return puzzle; }难度与挖洞数量的对应关系我常用下面这张表作为参考。注意挖洞数不是唯一指标同一挖洞数量下题目的解法路径长短也会影响体感难度所以生成后最好再做一次“是否是唯一解”的最终校验难度挖洞数范围提示数范围生成耗时参考入门30~3447~51极快单题 10ms普通40~4635~41快但偶发回溯次数增多困难50~5625~31慢可能需要数百次求解调用挖洞时最常踩的坑是随机顺序不佳导致挖洞数永远达不到目标值。比如挖到 50 个后剩下的每个格子删掉都会变成多解这时dug会卡在某个值上。解法是不强求达到精确挖洞数而是当连续失败次数超过阈值比如 20 次时直接接受当前盘面。另一个坑是countSolutions没有limit参数导致困难盘面数解时把整棵搜索树遍历完CPU 会被拉满。上面代码里的limit 2就是为了让唯一解校验尽早返回。3. 微信小程序端数独棋盘渲染与交互实现3.1 用 view 网格渲染 9x9 棋盘而不是 canvas数独棋盘有两种渲染方案canvas 绘制和 view 网格。canvas 在绘制粗线、高亮底色时性能更好但要自己处理点击命中和重绘逻辑view 网格用 WXML 循环渲染微信小程序框架的 setData 天然能驱动单格更新交互代码更简单。对于一个 9×9 的案例级游戏view 网格完全够用所以我选择纯 view 实现只有 81 个节点性能没有任何压力。另一个好处是 view 方案天然支持 css 动画和hover-class比如点击格子时的按压反馈不用额外写触摸事件。而且数独棋盘不是高频刷新场景只有“选中格子”“填入数字”“高亮行列”三个状态变化setData 一次更新十几个节点的 class 完全在性能安全线内。canvas 方案更适合那种需要复杂动画、粒子特效的微信小程序游戏开发场景数独用不上。3.2 wxml 结构与 wxss 宫格线实现棋盘 WXML 用两层循环外层循环 9 行内层循环 9 列。每格的 class 由该格状态动态拼接包括是否属于当前行/列/宫、是否为同数字格、是否可编辑等view classboard view classrow wx:for{{board}} wx:keyindex wx:for-itemrowData wx:for-indexrowIdx view wx:for{{rowData}} wx:keyindex wx:for-itemcell wx:for-indexcolIdx classcell {{cell.highlight ? highlight : }} {{cell.sameNum ? same-num : }} {{cell.editable ? editable : fixed}} {{selectedRow rowIdx selectedCol colIdx ? selected : }} >.cell { width: 11.11%; height: 88rpx; display: flex; align-items: center; justify-content: center; box-sizing: border-box; border-right: 1rpx solid #d0d0d0; border-bottom: 1rpx solid #d0d0d0; position: relative; } .cell:nth-child(3n) { border-right: 4rpx solid #333; } .row:nth-child(3n) .cell { border-bottom: 4rpx solid #333; } .row:first-child .cell { border-top: 4rpx solid #333; } .cell:first-child { border-left: 4rpx solid #333; }注意这里的宽度用百分比11.11%对应 1/9。如果整个棋盘容器宽度是 750rpx11.11% 会得到约 83rpx 的格子宽与高度 88rpx 接近但不等。为了画成正方形建议把格子高度也设为 11.11% 的父容器高度或者直接把棋盘容器设为固定宽度 750rpx、固定高度 750rpx让格子按百分比自适应正方形。宫格线的边框还有一个容易翻车的细节同时给所有格子加border-right和border-bottom后第 3、6 行的下边框会被border: 4rpx solid #333盖住但第 9 行没有border-bottom时棋盘底部边界会缺失。上面代码用row:first-child的border-top补了上边界但底部还需要额外给最后一行的格子补或者给棋盘容器加border-bottom: 4rpx solid #333二选一。3.3 选数、填入与错误反馈的完整数据流棋盘交互的数据流是点击格子 → 更新选中态与高亮态 → 点击数字键盘 → 校验并更新数据 → setData 驱动重渲染。这里不建议在数字键盘点击时直接修改board数组里的对象再整体setData({ board: this.data.board })因为小程序对引用类型做整体 setData 会序列化整个棋盘9×9 无所谓但这会掩盖“到底哪一格变了”的事实不利于后续扩展撤销功能。我一般会先构造一个新数组再 setData 这个新数组onCellTap(e) { const { row, col } e.currentTarget.dataset; if (this.data.board[row][col].editable false) return; this.setData({ selectedRow: row, selectedCol: col, }); this.updateHighlights(row, col); }, onNumberInput(e) { const { num } e.currentTarget.dataset; if (this.data.selectedRow null) return; const { selectedRow: r, selectedCol: c } this.data; const board this.data.board.map(row row.slice()); const cell board[r][c]; if (!cell.editable) return; const solution this.data.solution[r][c]; if (num solution) { cell.value num; cell.pencils []; } else { cell.wrong true; cell.value num; this.data.mistakes; } this.setData({ board, mistakes: this.data.mistakes }); this.checkWin(); }updateHighlights负责把与选中格同行、同列、同宫、同数字的格子标记为高亮。这个函数不需要 setData 整个棋盘可以把需要变化的格子索引收集后只更新它们的 class。参数上要注意>startTimer() { if (this.timer) return; this.startTime Date.now() - this.data.elapsed * 1000; this.timer setInterval(() { const elapsed Math.floor((Date.now() - this.startTime) / 1000); this.setData({ elapsed }); }, 1000); }, onHide() { if (this.timer) { clearInterval(this.timer); this.timer null; this.persist(); } }, onShow() { if (this.data.board !this.data.won) { this.startTimer(); } }这里的关键是this.startTime与this.data.elapsed的关系startTime是“对局开始的时间起点”当用户切后台再回来时onShow会重新根据Date.now() - startTime计算即使小程序在后台挂起了十几分钟计时也不会丢。而persist()里保存的elapsed是给下次启动时恢复用的恢复对局时用存档里的elapsed反推一个startTime再启动计时器。注意onHide和onShow在开发者工具里模拟器切换、真机切后台、小程序被扫码进入其他页面等场景下都会触发不要在里面写重复初始化的逻辑否则会出现计时器被创建两次、一秒跳两秒的情况。我习惯在startTimer开头判断this.timer是否存在严格保证同一时刻只有一个计时器实例。4.3 用 wx.setStorageSync 保存进度与恢复对局存档的最小单位是“一场对局”。保存时机有两个每次填入数字后立即保存以及onHide时保存。前者防止用户填了很多但没退出进程就被杀后者兜底切后台的情况。注意wx.setStorageSync有同步阻塞数据量小无所谓但不要频繁在输入时保存整盘 9×9 的对象数组可以做一个 500ms 的防抖。persist() { const saveData { version: 1, puzzle: this.data.puzzle, solution: this.data.solution, board: this.data.board.map(row row.map(cell ({ value: cell.value, wrong: cell.wrong, editable: cell.editable, pencils: cell.pencils || [], }))), elapsed: this.data.elapsed, mistakes: this.data.mistakes, difficulty: this.data.difficulty, timestamp: Date.now(), }; wx.setStorageSync(sudoku_save_v1, saveData); }, restore() { const data wx.getStorageSync(sudoku_save_v1); if (!data || data.version ! 1) return null; const elapsedOnHide Math.floor((Date.now() - data.timestamp) / 1000); data.elapsed elapsedOnHide; return data; }存档里的timestamp字段常被忽略但很重要用户把小程序切到后台 3 小时再点回来时如果只恢复存档里的elapsed中间 3 小时的“后台发呆”时间就不会计入。上面的代码会在restore时把timestamp到现在的时间差加到elapsed上。如果你认为后台挂机时间不应计入对局时长就不要加这一段保存时存最新时间戳恢复时直接读elapsed即可。这里没有标准答案产品需求不同选择不同。wx.env.user_data_path在这个场景里可以用到如果后续要做“导出棋局文件分享给好友”可以把棋局数据写成 JSON 文件保存在wx.env.user_data_path下通过wx.shareFileMessage发送。对于纯进度存档wx.setStorageSync已经足够不需要碰文件系统。恢复时还有一个边界情况存档里的puzzle和当前实际生成的题目是否对应。如果版本升级改了难度参数或生成算法旧存档里的solution可能与puzzle不匹配导致校验逻辑出 bug。所以在saveData里加version字段恢复时版本不一致直接丢弃存档回退到新开局是最稳妥的。5. 真机调试、加载页优化与发布前的 3 个检查点5.1 首屏提速与「修改刚进入的加载页面」的常见做法微信小程序项目实例里“打开就白屏、过一会才有内容”是高频问题。数独游戏的首屏资源主要是 js 逻辑包和页面渲染优化思路有两个一是把求解器、生成器这些纯计算逻辑放到分包或独立文件里利用分包懒加载减少首包体积二是在onLoad里不要同步生成完整棋盘而是先生成一份“模板盘”快速渲染再在下一帧生成正式题目。“修改刚进入的加载页面”指的是替换小程序启动时默认的加载界面。官方支持在小程序后台配置启动页如果是原生开发可以在全局app.json里配合window配置的backgroundColor和navigationBarBackgroundColor让过渡更平滑。真正的自定义 loading 页需要在一进入就渲染的页面里用wx.showLoading或自带的全屏 loading 组件来遮挡初始化过程。数独题目生成非常快所以 loading 页只需遮住wx.getStorageSync的读取时间多数情况下一闪而过。不要在这里放动画和转圈超过一秒钟玩家会觉得卡顿。5.2 用 vConsole 与微信开发者工具抓包定位白屏和数据异常白屏问题排查的第一步是看 Console 有没有报错。开发者工具模拟器能重现大部分 JS 异常但真机上经常出现“工具正常、真机白屏”的情况常见原因是 ES6 语法不支持或基础库版本过低。比如Array.prototype.flat在低版本基础库上不存在数独生成器里如果用了它做数组展平工具里正常、真机崩溃。解决办法是在工具详情里把调试基础库切到 2.x 低位版本提前暴露语法兼容问题。微信小程序抓包主要针对网络请求异常。数独案例如果不需要远程接口纯本地生成就不存在域名白名单问题。但如果后续接入了“每日挑战”这类后端服务需要留意真机上wx.request必须配置合法域名开发者工具里可以勾选“不校验合法域名”绕过但这只是开发阶段的手段上线前一定要确认request 合法域名里配了服务器地址。用 Charles 之类的工具抓包时小程序真机的 HTTPS 请求默认走证书校验需要给手机安装并信任证书否则抓到的全是 TLS 握手失败容易误判成服务端问题。真机调试还有一个被低估的入口在真机上打开 vConsole可以直接看到wx.setStorageSync的读写异常和页面渲染警告。数独这类纯本地应用绝大多数 bug 都出在数据恢复与 setData 上vConsole 的 log 输出比抓包更直接。5.3 发布前检查点给数独案例加一个「笔记模式」提留存上线前有三个检查点值得过一遍。第一app.json里的navigationBarTitleText改成实际游戏名避免发布后顶部导航栏还叫“微信小程序”。如果用了自定义导航注意navigationStyle: custom后状态栏高度需要手动计算这也是“微信小程序顶部导航栏高度”这个问题的来源。第二真机走一遍“填错三次 → 切后台 → 杀进程 → 重新打开 → 恢复对局”的完整链路确认存档恢复后mistakes和错误标记仍然在。第三wx.setStorageSync的 key 加上版本后缀比如sudoku_save_v1避免后续发版时旧存档冲突。最后一个实用的功能扩展是“笔记模式”。数独玩家在困难盘面上需要做候选数标记相当于给每个格子维护一组可能数字。交互逻辑不复杂切换笔记模式后点数字不再直接填入而是在该格的候选数集合里增删数字。渲染上可以直接在格子内部用小号文字展示候选数棋盘上显示不下就用宫格分组每格最多显示 5 个候选数toggleNoteMode() { this.setData({ noteMode: !this.data.noteMode }); }, onNumberInput(e) { const { num } e.currentTarget.dataset; if (this.data.noteMode) { const board this.data.board.map(row row.slice()); const cell board[this.data.selectedRow][this.data.selectedCol]; if (!cell.editable) return; const pencils cell.pencils.includes(num) ? cell.pencils.filter(n n ! num) : [...cell.pencils, num].sort(); cell.pencils pencils; cell.value 0; this.setData({ board }); return; } // 正常填数逻辑 }注意cell.value 0这一行当格子已经填了数切笔记模式后点数字应该先清空实际值再进入候选态否则会出现格子里既显示 7 又显示候选数 3 的脏状态。这个细节不做玩家就会觉得笔记模式“坏了”。另外候选数渲染的 wxml 里用wx:for循环pencils数组时不需要特殊排序但填数模式下要把pencils置空否则切模式时残留候选数会干扰视野。做完笔记模式数独小程序的完整功能闭环就齐了生成、输入、提示、计时、存档、笔记这样的案例源码才有资格进简历上的参考项目列表。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进