
简介微信小程序双人五子棋项目实例适合具备基础前端知识、想进阶小程序游戏开发的初学者与移动端爱好者。资源为完整可运行工程解压后导入微信开发者工具即可直接体验双人对局。压缩包共10个文件包含4个json配置文件项目与页面配置、3个js逻辑脚本核心算法与工具函数、2个wxss样式表和1个wxml页面结构整体仅6KB轻量清晰。该项目已有1895人学习使用。通过它可掌握小程序的目录结构、数据绑定与事件处理重点理解15×15棋盘的绘制与点击落子、五子连珠胜负判断、悔棋记录以及双人轮流对弈的状态管理思路项目模块划分清楚为独立开发互动型小程序提供了可直接参考的样板。1. 从双人五子棋看小程序棋盘渲染的选型边界双人五子棋在小程序里看起来是个入门级项目但真要把它写得顺手第一个坑就出在棋盘渲染上15×15 的交叉点用 Canvas 逐帧重绘和用 view 组件堆格子走的完全是两条技术路线。Canvas 的绘图 API 对小棋盘来说有点大材小用而且在小程序里 canvas 的触摸事件坐标换算要额外处理反而容易在 iOS 真机上出现点击偏移。这个项目实例正好拿来做对照组——它用纯 WXML WXSS 铺棋盘、用 JavaScript 管状态把博弈逻辑和渲染层彻底分开。适合刚接触微信小程序但已经写过一点前端页面的开发者通过它你能同时看清小程序的视图层结构、事件冒泡机制、以及一局棋从落子到终局的完整状态机该怎么组织。理解了这个边界后面换 Canvas 或者接人机对战算法时才知道哪些能力是白送的、哪些得自己补。2. 棋盘数据结构与 WXML 布局先把格子铺对2.1 用二维数组表示棋盘的依据五子棋盘本质是一个二维坐标系统15×15 的交叉点可以抽象成 15×15 的二维数组每个元素只有三种状态0空、1黑子、2白子。选数组而不是邻接表或者对象 Map是因为后续做胜利判定时要频繁按坐标访问邻居节点数组按下标访问是 O(1) 的而且 JSON 序列化方便存进 storage 做对局续传也容易。这个项目里utils/util.js承担了棋盘初始化的职责下面是一个标准的 15×15 初始化函数。// utils/util.js function createBoard(size 15) { const board new Array(size); for (let i 0; i size; i) { board[i] new Array(size).fill(0); } return board; } module.exports { createBoard };createBoard接收棋盘边长size默认 15。这里必须用new Array(size)先建外层再对每个内层执行new Array(size).fill(0)不能图省事写new Array(size).fill(new Array(size).fill(0))否则所有行会指向同一个数组引用改一个点全盘跟着变。这是新手最容易踩的 JavaScript 引用陷阱。2.2 页面数据与 WXML 双层循环渲染棋盘放到页面里有两种常见做法把整个二维数组塞进data由 WXML 用wx:for双层遍历生成格子或者只维护落子记录列表渲染时动态计算格子坐标。前者直观但每次setData要传整块二维数组数据量在 15×15 场景下约 225 个数字体积可控后者数据少但渲染逻辑复杂不适合这个项目的教学定位。在pages/index/index.wxml里棋盘部分这样组织。view classboard view classboard-row wx:for{{board}} wx:for-itemrow wx:for-indexrowIndex wx:keyrowIndex view classcell {{item ? cell-black : (colValue 0 ? : cell-white)}} wx:for{{row}} wx:for-itemcolValue wx:for-indexcolIndex wx:keycolIndex >/* pages/index/index.wxss */ .board { width: 690rpx; margin: 20rpx auto; background: #dcb35c; border-radius: 8rpx; padding: 12rpx; box-sizing: border-box; display: flex; flex-wrap: wrap; } .board-row { display: flex; width: 100%; } .cell { width: 44rpx; height: 44rpx; box-sizing: border-box; border-right: 2rpx solid #8b6914; border-bottom: 2rpx solid #8b6914; position: relative; } .cell:nth-child(15) { border-right: none; }这里棋盘总宽 690rpx 减去 padding 24rpx剩余 666rpx 除以 15 个格子约等于 44.4rpxWXSS 不支持小数位精确计算所以直接写 44rpx最后一行格子会略微多出一点——视觉上看不出来但如果你后续要给棋盘加星位标记坐标定位就要用百分比或者calc()别依赖累加式布局。提示棋盘底色用深色木纹色#dcb35c棋子用黑白圆点这是五子棋项目的通用视觉方案可读性最好。2.4 落子事件与坐标换算onCellTap是页面逻辑层的入口函数负责把 WXML 传来的行列号落到数组并渲染棋子。// pages/index/index.js Page({ data: { board: [], currentPlayer: 1, gameOver: false }, onLoad() { this.setData({ board: util.createBoard(15) }); }, onCellTap(e) { const row e.currentTarget.dataset.row; const col e.currentTarget.dataset.col; if (this.data.gameOver) return; if (this.data.board[row][col] ! 0) return; const board this.data.board; board[row][col] this.data.currentPlayer; this.setData({ board: board, currentPlayer: this.data.currentPlayer 1 ? 2 : 1 }); } });e.currentTarget.dataset.row取到的是字符串还是数字取决于 WXML 中的写法这里我们在模板里硬编码了整数但小程序的事件对象里 dataset 统一按字符串透传。严谨的做法是parseInt(e.currentTarget.dataset.row, 10)否则后续拿board[row]时 JavaScript 会隐式转换能跑但容易埋藏类型隐患。当前落子方用currentPlayer字段维护1 为黑棋、2 为白棋。每次合法落子后切换这就是最简单的回合状态机。到这里棋盘已经能下棋了但缺了胜负判定这一步留到下一章。3. 胜利条件判定从暴力四方向扫描到提前终止3.1 为什么需要检查八个方向而不是四个五子棋的连珠判定规则是任一棋子在横、竖、左斜、右斜四个线性方向上连成五个及以上即为胜。注意「五个及以上」这个表述实战中棋子可能形成六连甚至长连。标准五子棋规则下长连是否算赢在各地玩法里有差异小程序项目里一般统一定为「大于等于五即胜」避免规则分支。四个线性方向的正反两侧加起来是八个方向但写代码时只需要扫描四个基向量每个基向量向正反双向延伸计数。四个基向量分别是右方向(0, 1)、下方向(1, 0)、右下斜向(1, 1)、右上斜向(1, -1)。为什么不写成八个方向分别扫描因为双向扫描需要做两次边界判断代码重复度高合并成一次单方向扩散更紧凑。3.2 逐方向计数的胜利判定函数下面是utils/util.js里的胜利判定实现接收棋盘、落子行、落子列、当前玩家作为入参。// utils/util.js function checkWin(board, row, col, player) { const directions [ [0, 1], // 水平 [1, 0], // 垂直 [1, 1], // 右下斜 [1, -1] // 右上斜 ]; for (const [dx, dy] of directions) { let count 1; // 正方向 for (let i 1; i 5; i) { const nr row dx * i; const nc col dy * i; if (!isInBoard(nr, nc)) break; if (board[nr][nc] ! player) break; count; } // 反方向 for (let i 1; i 5; i) { const nr row - dx * i; const nc col - dy * i; if (!isInBoard(nr, nc)) break; if (board[nr][nc] ! player) break; count; } if (count 5) return true; } return false; } function isInBoard(row, col) { return row 0 row 15 col 0 col 15; }这段代码的关键是循环变量i从 1 到 4因为落子点本身的 count 已初始化为 1向正方向最多延伸四格就足以覆盖「以当前点为中心形成五连」的全部可能反方向同理。每次延伸前先做边界检查再比对棋子颜色一旦遇到空格或对方棋子就break掉当前方向。每步落子都调用checkWin的代价是 O(4×8) 常数时间在 225 格的棋盘上几乎可以忽略。但如果棋盘升级到 19 路或需要支持 AI 搜索就要改成「增量更新胜利判定」——只维护四个方向上的连续棋子长度数组每落一子更新受影响的坐标串复杂度从 O(常数) 变成 O(1)这是后面要讲的进阶方向。3.3 把判定接入落子流程checkWin返回值是布尔类型接入onCellTap时要在切换currentPlayer之前判断胜负。// pages/index/index.js const util require(../../utils/util.js); Page({ // 省略 data 与 onLoad onCellTap(e) { const row parseInt(e.currentTarget.dataset.row, 10); const col parseInt(e.currentTarget.dataset.col, 10); if (this.data.gameOver) return; if (this.data.board[row][col] ! 0) return; const board this.data.board; const player this.data.currentPlayer; board[row][col] player; if (util.checkWin(board, row, col, player)) { this.setData({ board: board, gameOver: true, winner: player }); wx.showModal({ title: 本局结束, content: (player 1 ? 黑棋 : 白棋) 胜, showCancel: false }); return; } this.setData({ board: board, currentPlayer: player 1 ? 2 : 1 }); } });这里注意一个时序问题checkWin必须在currentPlayer切换前调用否则拿到的player已经是对方。我习惯在函数开头先const player this.data.currentPlayer保存当前值后面所有逻辑共用这个局部变量避免多次this.data读取之间状态被意外修改。3.4 平局判定与对局状态机平局条件是棋盘被下满225 手且无人胜出。需要维护一个moveCount字段每次合法落子moveCount当计数达到 225 时触发平局弹窗。这里有一个隐含问题是否需要在每步都遍历全盘检查剩余空位不需要moveCount维护的是已落子数与棋盘剩余空位一一对应O(1) 可得。游戏状态机的完整流转是playing对局中→blackWin/whiteWin/draw终局。页面里用gameOver布尔量加winner字段表达终局状态维度拆成两个变量比维护一个枚举更直观但代价是可能出现「gameOver为 true 但winner未赋值」的中间态——实际开发中可以在进入终局时统一用一个setData同时更新两个字段保证数据一致。4. 悔棋与重开历史记录栈的设计时机4.1 为什么悔棋要单独建栈而不是重新渲染棋盘悔棋的朴素实现是「把当前落子点清空」但这样只能悔一步而且无法处理「悔两步后玩家改变路线」的持续性需求。正确做法是维护一个历史记录栈栈里存每一步的坐标行、列、玩家悔棋时弹栈并清空对应格子。从数据结构角度历史记录栈本质是「操作序列」的持久化。它和棋盘二维数组构成互为备份的关系棋盘数组代表当前快照历史栈代表到达快照的路径。这一步区分很关键——如果你只存棋盘数组不存历史悔棋时只能倒退一个状态回退到任意历史点就要额外做整盘重放O(n²) 开销下写起来又容易出错。// pages/index/index.js Page({ data: { board: [], moveHistory: [], currentPlayer: 1, gameOver: false }, onCellTap(e) { // 落子逻辑成功后执行追加 const move { row: row, col: col, player: player }; const history this.data.moveHistory.concat([move]); this.setData({ moveHistory: history }); }, onUndo() { if (this.data.moveHistory.length 0) return; if (this.data.gameOver) return; const history this.data.moveHistory.slice(0, -1); const lastMove this.data.moveHistory[this.data.moveHistory.length - 1]; const board this.data.board; board[lastMove.row][lastMove.col] 0; this.setData({ board: board, moveHistory: history, currentPlayer: lastMove.player }); } });悔棋操作回退currentPlayer为lastMove.player这意味着轮到刚悔棋的那一方重新落子。有些棋类 App 会做成「悔棋后轮到对方」两种规则各有拥趸。项目里我建议采用lastMove.player逻辑更自然——你悔的是自己刚下的那手重新下的还是你。4.2 悔棋边界终局后是否允许代码里在onUndo开头加了gameOver判断。实际项目里这里有两种产品决策终局后禁止悔棋或者终局后允许悔棋并回到对局中。前者实现简单但玩家输棋想复盘会很憋屈后者更友好但需要同步重置gameOver、winner字段并且currentPlayer回退到正确的一方。我一般这样处理终局弹窗里放「再来一局」和「回顾棋谱」两个按钮回顾棋谱不靠悔棋实现而是按历史重放。悔棋只存在于对局中这样状态机边界清晰不会出现「gameOver true 但棋盘能继续改」的脏状态。4.3 重开一局的三种实现方式对比重开功能有三种常见做法按性能从劣到优排列。方案实现适用场景整页重启wx.reLaunch到当前页清空全部 data最省事但会白屏闪跳观感差重置数据重新调用onLoad里的初始化逻辑需要把初始化代码抽成公共函数避免逻辑重复增量清空遍历历史栈把已有棋子逐个清空数据量大时性能好但实现复杂度最高15×15 棋盘只有 225 格选「重置数据」最合适。在onLoad里把initGame()拆出来重开时直接调用它。// pages/index/index.js Page({ initGame() { this.setData({ board: util.createBoard(15), moveHistory: [], currentPlayer: 1, gameOver: false, winner: 0 }); }, onLoad() { this.initGame(); }, onRestart() { this.initGame(); } });initGame把所有对局相关字段统一重置比散落各处的setData更好维护。注意这里currentPlayer永远从 1黑棋开始如果项目要支持「黑白任选」初始化参数要改成可配置。5. 回放与导出用 moveHistory 做棋谱的数据变现5.1 按历史栈顺序重放对局moveHistory除了悔棋还能直接转化为「复盘回放」功能。回放的思路是把历史栈逐条读出来每读取一条就更新棋盘数组上对应坐标配合setTimeout或wx.nextTick做时间间隔控制。// pages/index/index.js Page({ onReplay() { if (this.data.gameOver false) { wx.showToast({ title: 对局结束后才能回放, icon: none }); return; } const history this.data.moveHistory; const emptyBoard util.createBoard(15); let step 0; this.setData({ board: emptyBoard, replaying: true }); const timer setInterval(() { if (step history.length) { clearInterval(timer); this.setData({ replaying: false }); return; } const move history[step]; const board this.data.board; board[move.row][move.col] move.player; this.setData({ board: board }); step; }, 600); } });这个实现里setInterval间隔固定 600ms约等于每秒两步适合人眼跟谱。参数可调复盘用 300ms 快速浏览教学用 1000ms 慢速分解。注意回放过程中不能让玩家点击棋盘落子要在onCellTap开头加if (this.data.replaying) return;拦截。5.2 导出对局记录的 JSON 结构把moveHistory序列化成 JSON 存到本地或发给朋友是这个小程序项目加上「分享棋谱」功能的最短路径。微信小程序里写入本地文件的接口是wx.getFileSystemManager().writeFile保存路径可以选wx.env.USER_DATA_PATH这是小程序用户私有目录不会污染缓存。// pages/index/index.js onExport() { const history this.data.moveHistory; const record { version: gomoku-1.0, boardSize: 15, moves: history }; const filePath wx.env.USER_DATA_PATH /replay- Date.now() .json; const fs wx.getFileSystemManager(); fs.writeFile({ filePath: filePath, data: JSON.stringify(record), encoding: utf8, success() { wx.showToast({ title: 棋谱已保存, icon: success }); }, fail(err) { console.error(导出失败, err); } }); }version字段很重要棋盘规则如果从 15 路扩到 19 路老棋谱的boardSize与新版本不一致回放前要先做校验。moves数组元素是包含row、col、player的对象和moveHistory里的结构完全一致实现了写入读出的双向兼容。5.3 复盘中的一个隐蔽 Bug定时器泄漏代码里的setInterval在clearInterval(timer)前一直挂载。如果用户在回放中途点了返回上一页或直接退出小程序定时器不会被释放页面销毁后还在跑轻则数据空转重则触发setData警告。正确做法是页面onUnload里清除定时器。// pages/index/index.js Page({ onUnload() { if (this.replayTimer) { clearInterval(this.replayTimer); } }, onReplay() { // 先清理上次残留定时器 if (this.replayTimer) clearInterval(this.replayTimer); let step 0; this.replayTimer setInterval(() { // 回放逻辑 }, 600); } });把定时器 ID 存到this.replayTimer而不是data里因为定时器 ID 不需要驱动视图更新放进data反而会多一次无谓的渲染。这是小程序性能优化里一个常被忽视的细分点data里只放需要渲染到界面的数据内部状态一律挂到this上。本文还有配套的精品资源点击获取