ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序数独源码拆解:从WXML渲染到回溯算法

微信小程序数独源码拆解:从WXML渲染到回溯算法 简介这是可直接运行的微信小程序数独游戏案例源码包面向有一定前端或JavaScript基础、想通过游戏类项目入门小程序开发的读者。压缩包共53个文件以JavaScript逻辑代码、WXML页面结构、WXSS样式和JSON配置为主体另含10余张JPG/PNG截图与一个GIF演示动图整体约975KB便于在微信开发者工具中快速导入和查看。目前已有1163人学习。源码覆盖数独盘面初始化、用户点击输入、数字合法性校验、终局判断等完整流程并演示了wx:for循环渲染棋盘、bindtap事件绑定、data状态管理及setData更新界面、wx.setStorageSync本地存档等小程序开发核心技巧游戏自动生成题目与检查唯一解的算法还可用于练习递归与回溯。适合作为课程设计、毕业设计或自学练手的参考也可在此基础上扩展难度、计时或提示功能。1. 微信小程序数独源码为什么要拆这个项目拿到这份“微信小程序开发-游戏类-数独案例源码.zip”时我第一件事是扫描压缩包里的目录。除了三张截图和一个名为 sudoLite 的资源引用之外核心就是一个纯前端的小程序数独实现。数独规则很收敛9×9 网格每行、每列、每个3×3宫格内数字1到9不重复。但它恰好踩中了小程序开发里最典型的几个技术点WXML 的双层循环渲染、WXSS 的宫格边框处理、JavaScript 的递归回溯、事件绑定与 setData 状态同步以及用本地存储把一局没下完的棋留住。对于刚接触小程序的人它比待办清单、记账本那些 CRUD 示例更有信息量因为你要面对的是真正的算法逻辑对于写过业务页面的熟手这份源码也能让你重新审视 setData 的调用频率和状态结构设计。数独类小游戏看起来简单但要把“生成题目—用户交互—校验—存档”这条链路打磨顺中间有不少小程序特有的坑。2. 项目结构与小程序渲染骨架2.1 目录结构与 app.json 配置解压源码后用微信开发者工具导入项目首先要看的是 app.json。这个文件是小程序的全局配置入口决定了页面注册关系和导航栏样式。数独项目通常只有一两个页面把游戏主界面放在 pages/index/index 下是常见做法。{ pages: [ pages/index/index ], window: { navigationBarTitleText: 数独, navigationBarBackgroundColor: #2c3e50, navigationBarTextStyle: white }, style: v2, sitemapLocation: sitemap.json }这里pages数组的第一个元素就是启动页当项目包含多个页面时这个顺序决定了哪个页面先被加载。数独游戏不适合用 tabBar 切换页面因为棋盘需要最大的可视区域底部导航会挤压操作空间。navigationBarBackgroundColor设置为深色能让九宫格在视觉上更加突出同时navigationBarTextStyle只能选white或black深色背景配白字是默认组合。2.2 WXML 棋盘渲染双层 wx:for 生成 81 个单元格数独棋盘的静态结构是 9×9最直接的写法是用两层wx:for遍历二维数组。源码中通常把盘面数据命名为board其中board[row][col]表示某行某列的数字0 代表未填充的空白格。view classsudoku-board view wx:for{{board}} wx:for-itemrow wx:for-indexrowIndex wx:keyindex classsudoku-row view wx:for{{row}} wx:for-itemcell wx:for-indexcolIndex wx:keyindex classsudoku-cell {{cell 0 ? empty : }} {{currentRow rowIndex currentCol colIndex ? selected : }} >.sudoku-board { width: 750rpx; display: flex; flex-direction: column; border: 4rpx solid #34495e; border-radius: 12rpx; overflow: hidden; background-color: #fff; } .sudoku-row { display: flex; height: 100rpx; } .sudoku-cell { flex: 1; display: flex; align-items: center; justify-content: center; border-right: 1rpx solid #bdc3c7; border-bottom: 1rpx solid #bdc3c7; }在只使用基础样式时第 3 列、第 6 列的右侧边框仍然是细线不会形成宫格分界。通常是用nth-child选择器加粗或者更稳妥地在 WXML 中动态拼接列号 class.sudoku-cell.col-3, .sudoku-cell.col-6 { border-right: 4rpx solid #34495e; } .sudoku-cell.col-9 { border-right: none; } .sudoku-row.row-3, .sudoku-row.row-6 { border-bottom: 4rpx solid #34495e; }对应的 WXML 要在 class 绑定上增加col-{{colIndex 1}}和row-{{rowIndex 1}}。这里有个细节为什么是col-3和col-6因为每 3 列结束就是宫格的边界第 3 列右边是第一个宫格的右边界第 6 列右边是第二个宫格的右边界同理第 9 列是棋盘最右列它的右边界由.sudoku-board的外边框承担所以去掉自身右边框否则会出现双线。rpx 单位在微信小程序中的表现是任何屏幕宽度都按 750rpx 计。棋盘宽度直接使用 750rpx 可以让它占满全屏宽度但为了适配罗盘和 iPhone 的刘海屏更稳妥的做法是在外层套一个padding容器棋盘宽度改为100%由父容器约束。3. 数独核心逻辑盘面生成与唯一解校验3.1 从种子终盘生成完整盘面数独项目的算法部分决定了这个源码的含金量。生成一个新游戏的常见做法不是用随机数硬拼而是从一个合法的种子终盘出发通过行组置换和数字置换产生新的终盘。下面这段代码代表了一种可复现的生成方式。const SUDOKU_SIZE 9; const BOX_SIZE 3; function shuffle(arr) { const a arr.slice(); for (let i a.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [a[i], a[j]] [a[j], a[i]]; } return a; } function generateSolvedSudoku() { const seed [ [1, 2, 3, 4, 5, 6, 7, 8, 9], [4, 5, 6, 7, 8, 9, 1, 2, 3], [7, 8, 9, 1, 2, 3, 4, 5, 6], [2, 3, 4, 5, 6, 7, 8, 9, 1], [5, 6, 7, 8, 9, 1, 2, 3, 4], [8, 9, 1, 2, 3, 4, 5, 6, 7], [3, 4, 5, 6, 7, 8, 9, 1, 2], [6, 7, 8, 9, 1, 2, 3, 4, 5], [9, 1, 2, 3, 4, 5, 6, 7, 8] ]; const bandOrder shuffle([0, 1, 2]); const newRows []; for (let b 0; b 3; b) { const band bandOrder[b]; const rowOrder shuffle([0, 1, 2]); for (let r 0; r 3; r) { newRows.push(seed[band * 3 rowOrder[r]].slice()); } } const digitMap shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); return newRows.map(row row.map(num digitMap[num - 1])); }这段代码的核心是两种交换操作。第一种是行组交换把 9 行分成三个 band每个 band 包含 3 行先交换 band 的顺序再在每个 band 内部交换行的顺序。这样做既保留了列方向不重复的性质又让盘面看起来完全不同。第二种是数字置换将数字 1-9 随机映射成另一组 1-9 的排列例如把原来的 1 全部变成 52 全部变成 8。数字置换不会破坏行、列、宫的唯一性只是换了一张数字表皮。shuffle函数使用 Fisher-Yates 洗牌算法这是生产环境里最常用的随机排列写法。这里刻意使用arr.slice()创建副本避免修改外部传入的数组因为后续算法依赖原始种子盘面。3.2 挖空与唯一解验证countSolutions得到终盘后下一步是挖空。挖空不是简单地把某些格子设为 0必须保证题目有唯一解。如果一道数独题有两种甚至多种解就会被玩家视为有 bug。因此源码中会先随机选择格子挖掉每挖一个就调用求解器检查解的数量。function countSolutions(board, limit 2) { let count 0; const boardCopy board.map(row row.slice()); function recursive() { if (count limit) return; const empty findEmptyCell(boardCopy); if (!empty) { count; return; } const [r, c] empty; for (let num 1; num 9; num) { if (isValid(boardCopy, r, c, num)) { boardCopy[r][c] num; recursive(); boardCopy[r][c] 0; } } } recursive(); return count; } function findEmptyCell(board) { for (let i 0; i SUDOKU_SIZE; i) { for (let j 0; j SUDOKU_SIZE; j) { if (board[i][j] 0) return [i, j]; } } return null; }countSolutions的limit参数是这里的点睛之笔。因为只需要判断是否唯一解所以一旦找到第二个解就立即停止不必完整遍历全部解空间。递归函数先找第一个空格然后依次尝试 1-9能填就继续递归走不通就回溯。当找不到空位时说明当前盘面已经被填满找到一组解计数器加一。挖空流程放在页面逻辑里时会结合一个挖空数量参数。简单难度挖掉 36 个数字中等挖 45 个困难挖 54 个。每挖一个数字先备份再调用countSolutions验证如果返回的解数量不等于 1就把数字回填。这个过程的耗时取决于解空间的密度源码中如果做了优化会把待挖格子的顺序打乱优先挖从中心宫格开始的格子因为中心格对唯一性的影响更敏感。3.3 用户输入的实时校验isValid玩家每次填入数字都要立刻验证是否与横排、竖列和宫格冲突。这个函数是整局游戏中被调用频率最高的算法函数必须简洁高效。function isValid(board, row, col, num) { for (let i 0; i SUDOKU_SIZE; i) { if (i ! col board[row][i] num) return false; } for (let i 0; i SUDOKU_SIZE; i) { if (i ! row board[i][col] num) return false; } const boxRow Math.floor(row / BOX_SIZE) * BOX_SIZE; const boxCol Math.floor(col / BOX_SIZE) * BOX_SIZE; for (let i boxRow; i boxRow BOX_SIZE; i) { for (let j boxCol; j boxCol BOX_SIZE; j) { if ((i ! row || j ! col) board[i][j] num) return false; } } return true; }这段代码的逻辑很直白行扫描跳过当前列列扫描跳过当前行宫扫描跳过当前格自身。Math.floor(row / BOX_SIZE) * BOX_SIZE是计算宫格左上角坐标的关键例如row 44 / 3取整为 1乘以 3 得到 3也就是第二个宫格行索引 3-5的起始行。这里有个容易被忽略的点isValid接收的board是最新的盘面但校验的num只是待填值还没有写入board所以必须通过i ! col之类的条件跳过当前格。3.4 使用回溯求解完整答案当玩家点击提示按钮或者想直接查看答案时需要快速求出一个可行解。递归回溯是数独求解的标准做法源码中的实现通常如下。function solveSudoku(board) { const boardCopy board.map(row row.slice()); return solve(boardCopy) ? boardCopy : null; } function solve(board) { const empty findEmptyCell(board); if (!empty) return true; const [r, c] empty; for (let num 1; num 9; num) { if (isValid(board, r, c, num)) { board[r][c] num; if (solve(board)) return true; board[r][c] 0; } } return false; }每次尝试一个数字都先经过isValid剪枝所以实际搜索空间远小于最坏情况的9^空格数。solve找到一组解就返回true不再继续搜索这与countSolutions的区别在于只需要一个解。提示功能可以利用这个求解器用户需要提示时对当前盘面求解然后把第一个空格填上答案。4. 交互与状态管理事件绑定、setData 与本地存储4.1 单元格点击与数字按钮的事件链小程序里的交互本质上是通过事件绑定把用户触摸转换成 JS 逻辑。源码中每个单元格绑定了bindtaponCellTap底部数字键盘的每一个按钮绑定bindtaponNumberTap事件参数通过>onCellTap(event) { const { row, col } event.currentTarget.dataset; const { puzzle, initialBoard } this.data; // 初始题目格子不允许选中 if (initialBoard[row][col] ! 0) { this.setData({ selectedCell: null }); return; } this.setData({ selectedCell: { row, col }, selectedNumber: puzzle[row][col] || 0 }); }, onNumberTap(event) { const num Number(event.currentTarget.dataset.num); const { selectedCell, puzzle } this.data; if (!selectedCell) return; const { row, col } selectedCell; const newPuzzle puzzle.map(r r.slice()); newPuzzle[row][col] num; this.setData({ puzzle: newPuzzle }); this.checkCompletion(); }event.currentTarget.dataset中的row和col是从>this.setData({ puzzle: newPuzzle, selectedNumber: num, stepCount: this.data.stepCount 1 });合并之后一次交互只触发一次通信。再进一步优化可以把当前棋盘的状态标记错误、高亮预先计算成一个cellStates数组WXML 直接读取cellStates[row][col]省去模板里的条件表达式。4.3 存档与续玩wx.setStorageSync 的读写时机数独游戏的特点是单局时间长几天后再打开还是想继续上次的盘面。小程序本地存储 API 提供了同步版本wx.setStorageSync适合保存这种小体积数据。源码中的保存通常覆盖onHide和onUnload两个生命周期。onLoad() { const saved wx.getStorageSync(sudoku_game); if (saved saved.puzzle) { this.setData({ puzzle: saved.puzzle, initialBoard: saved.initialBoard, timer: saved.timer || 0, difficulty: saved.difficulty || easy }); } else { this.startNewGame(); } }, onHide() { clearInterval(this.timerId); wx.setStorageSync(sudoku_game, { puzzle: this.data.puzzle, initialBoard: this.data.initialBoard, timer: this.data.timer, difficulty: this.data.difficulty }); }, onUnload() { clearInterval(this.timerId); }onHide在小程序切后台、页面被覆盖或导航跳转时都会触发非常适合做保存操作。onUnload是页面销毁时触发此时保存容易丢失最后几秒的操作因为用户可能退出得很突然。源码中如果做了防重复启动还会在onLoad里检查存档是否存在finished标记如果上一局已经完成就弹出新游戏选项避免每次进入都被旧局卡住。存储的数据结构里initialBoard是必须的。它记录了哪些数字是题目自带的存档恢复后这些数字仍然不能被修改否则用户可以把初始数字擦掉游戏就失去了意义。4.4 高亮与错误提示的 class 切换数独游戏的视觉反馈要求很高玩家需要一眼看出当前选中格、同数字关联格、冲突格和不可编辑格。WXML 中的多个条件 class 可以叠加但为了可读性最好把判断逻辑前置到 JS 中返回字符串。view classsudoku-cell {{isInitial(rowIndex, colIndex) ? initial : }} {{isError(rowIndex, colIndex) ? error : }} {{isSameNumber(rowIndex, colIndex) ? same-number : }} >buildCellClassMap() { const { puzzle, initialBoard, selectedCell, selectedNumber } this.data; const map []; for (let r 0; r 9; r) { map[r] []; for (let c 0; c 9; c) { const classes []; if (initialBoard[r][c] ! 0) classes.push(initial); if (selectedCell selectedCell.row r selectedCell.col c) classes.push(selected); if (selectedCell puzzle[r][c] selectedNumber puzzle[r][c] ! 0) classes.push(same-number); if (this.isError(r, c)) classes.push(error); map[r][c] classes.join( ); } } return map; }下面是一个状态 class 对照表解释数独源码里最常用的几种状态class 名称触发条件视觉建议initial初始题目自带的数字深色文字背景不可点selected当前点击的格子蓝色高亮边框same-number与选中格数字相同的格子浅灰色背景error当前格与同行/列/宫冲突红色边框或淡红背景empty未填入数字无文本可点击5. 进阶从能玩到好用的几个细节5.1 把数独算法封装成独立模块拆解源码时注意观察它的 JS 文件组织。数独生成与校验算法不应该写在 page 的 Page() 里而是应该独立成一个utils/sudoku.js通过 CommonJS 导出。这样做的最大好处是可以在小程序外部直接运行算法做测试。module.exports { isValid, generateSolvedSudoku, countSolutions, solveSudoku };页面 JS 里使用const sudoku require(../../utils/sudoku.js)引入。在微信开发者工具里你甚至可以在 Console 面板里跑sudoku.countSolutions(board)来验证当前盘面是不是多解这比反复编译查看界面高效得多。如果你的项目引入了单元测试框架也能直接对算法模块做测试。5.2 用自绘数字键盘替代原生 input不少初学者会把数独的每个格子做成input让用户通过系统键盘输数字。但在小程序里原生输入框的弹出键盘会挤压棋盘布局且无法与游戏风格统一。源码中更常见的做法是自绘一个 1-9 加删除键的固定键盘区放在棋盘下方。view classnumber-pad view wx:for{{[1,2,3,4,5,6,7,8,9]}} wx:key*this classnumber-key style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表