尧图精选

微信小程序五子棋实战拆解:从Canvas绘制到胜负判定全流程解析

🕒 发布时间:2026/9/13 14:01:22 📁 来源:尧图网络
简介一款基于微信小程序的五子棋单机游戏源码面向小程序入门开发者重点演示canvas绘图、落子交互与胜负判断等核心逻辑。项目共16个文件涵盖5个JavaScript逻辑脚本、5个JSON配置含页面配置与项目配置、3个WXSS样式文件、2个WXML页面结构文件及1个Markdown说明文档整体压缩包仅8KB结构精炼便于逐个文件研读。已有1633人学习/下载适合新手通过实际项目快速上手小程序开发流程。源码包含标准的小程序目录架构如index页面与logs页面、app全局配置、util工具函数下载后可导入开发者工具直接运行对照代码理解棋盘绘制、黑白棋轮流落子、五连胜利判定及重置对局等功能的实现思路也可在此基础上扩展人机对战或联机功能是巩固前端基础与小程序API用法的实用范例。1. 微信小程序五子棋一个能跑通全链路的轻量项目拆解五子棋可能是最适合拿来拆微信小程序的项目没有之一。它不需要服务端不需要云开发界面只有一个棋盘和几个按钮但整套小程序工程从app.json注册、Page 生命周期、Canvas 绘制到触摸事件处理一个不少。你花一个晚上把它跑起来第二天再去读那些动辄十几页的商城项目会发现骨架是同一套。这份game_gobang源码正好就是这种体量单机对弈、无外部依赖、纯前端逻辑。适合两类人刚接触小程序、想找一个能完整跑通 Canvas 交互的新手以及写过几年业务、想快速确认小程序 Canvas 2D 接口与旧版接口差异的老手。下面按工程结构、棋盘绘制、胜负判定、对局扩展和真机验证五个部分来拆。2. 工程结构与 Canvas 棋盘初始化2.1 小程序工程的骨架从 app.json 到页面文件拿到game_gobang.zip解压后你会看到一个标准微信小程序工程app.json做全局注册pages/index是游戏主页面utils/util.js放公共函数project.config.json控制开发者工具的项目配置sitemap.json管理索引。先看app.json这是整个应用的入口描述{ pages: [ pages/index/index, pages/logs/logs ], window: { navigationBarBackgroundColor: #4a4a4a, navigationBarTitleText: 五子棋, navigationBarTextStyle: white, backgroundColor: #f0e6d2 } }pages数组第一项是启动页这里指向pages/index即棋盘页logs是模板自带的日志页对本项目没有实际用途但保留它可以避免开发者工具报「页面不存在」的警告。window里的navigationBarTitleText会直接显示在手机顶部棋盘背景色用浅褐色系视觉上贴近木纹棋盘这是原作者在 UI 上的一个小设计如果要在game_gobang基础上改游戏名称只需要改这一处。WXML 部分非常薄view classcontainer canvas type2d idboardCanvas classboard/canvas view classcontrols button bindtaprestartGame重新开始/button button bindtapundoMove悔棋/button /view /view这里用到的是type2d的新版 Canvas 接口不是旧版wx.createCanvasContext。两者在后续获取上下文的方式上完全不同新手最容易在这一步卡住。新版 Canvas 2D 的canvas节点必须通过wx.createSelectorQuery()来获取并且要等页面渲染完成后再初始化否则拿到的node是null。2.2 获取 Canvas 2D 上下文与 DPR 适配新版 Canvas 2D 接口的一个关键点canvas.width和canvas.height设置的是物理像素而 WXSS 里width和height设置的是 CSS 像素。如果不做适配在高 DPR 屏幕上绘制出来的棋盘会模糊。常见做法是用wx.getWindowInfo()拿到pixelRatio然后把物理像素设为 CSS 尺寸乘以 DPR再用ctx.scale(dpr, dpr)把所有绘制操作统一缩放到 CSS 像素坐标系里const initCanvas () { const query wx.createSelectorQuery(); query.select(#boardCanvas) .fields({ node: true, size: true }) .exec((res) { const { node: canvas, width: cssWidth, height: cssHeight } res[0]; const dpr wx.getWindowInfo().pixelRatio; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); drawBoard(ctx, cssWidth, cssHeight); }); };fields({ node: true, size: true })里的size会把canvas在 WXML 里实际的 CSS 尺寸一并返回省去再用wx.createSelectorQuery().select(.board).boundingClientRect()单独查一次尺寸的步骤。ctx.scale(dpr, dpr)必须在任何绘制之前调用因为 Canvas 2D 的变换矩阵一旦设置后续所有坐标都会按其换算。drawBoard负责画 15 乘 15 的网格、星位标记和边框我一般把棋盘边距设为格子宽度的 0.5 倍而不是固定像素这样在不同屏幕宽度下棋盘都能保持视觉居中const drawBoard (ctx, width, height) { const boardSize 15; const margin 24; const cellSize (Math.min(width, height) - margin * 2) / (boardSize - 1); ctx.clearRect(0, 0, width, height); ctx.fillStyle #deB887; ctx.fillRect(0, 0, width, height); ctx.strokeStyle #5a4a3a; ctx.lineWidth 1; for (let i 0; i boardSize; i) { const x margin i * cellSize; ctx.beginPath(); ctx.moveTo(x, margin); ctx.lineTo(x, height - margin); ctx.stroke(); ctx.beginPath(); ctx.moveTo(margin, x); ctx.lineTo(width - margin, x); ctx.stroke(); } const starPoints [3, 7, 11]; ctx.fillStyle #5a4a3a; starPoints.forEach(row { starPoints.forEach(col { const x margin col * cellSize; const y margin row * cellSize; ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); }); }); };starPoints只取[3, 7, 11]对应 15 路棋盘上的天元和四个星位坐标第 4、8、12 交叉点。因为索引从 0 开始所以实际位置是 3、7、11。cellSize用(boardSize - 1)做分母是因为线段的端点是「格的边界」而不是「格的中心」这是画网格时最容易错的地方——用boardSize做分母会让最后一条线出界。3. 落子交互与五连判定算法3.1 触摸坐标到棋盘格子的换算用户手指点到屏幕上拿到的是 CSS 像素坐标棋盘是 15 路网格。这里需要把触摸点换算到最近的交叉点索引。换算公式是col Math.round((touchX - margin) / cellSize)然后检查该索引是否在 0 到 14 之间且该位置尚未落子。onCanvasTap(e) { const { x, y } e.detail; // canvas 2d 触摸事件中的坐标 const margin this.data.margin; const cellSize this.data.cellSize; const col Math.round((x - margin) / cellSize); const row Math.round((y - margin) / cellSize); if (row 0 || row 14 || col 0 || col 14) return; if (this.data.board[row][col] ! 0) return; this.placePiece(row, col); }注意这里e.detail直接携带了相对 Canvas 的坐标。如果在旧版 Canvas 接口里需要先用e.touches[0].x拿pageX再用wx.createSelectorQuery()获取偏移量手动换算。新版接口省了这一步代价是必须用canvas type2d绑定bindtap事件且e.detail只有在基础库 2.9.0 之后才稳定可用。:class落子前还要做一次board[row][col] ! 0的检查防止双人快速交替点击时同一个位置被下两次。这也是为什么落子记录要放在二维数组里而不是只记录一个lastMove——二维数组的查询是 O(1)而链表或数组尾部追加再遍历在 15 路棋盘上问题不大但状态管理上旧数据会被覆盖。3.2 四方向五连扫描从中心向外扩散胜负判定是五子棋的核心。每落一子只需要检查以该子为中心的横、竖、两条对角线四个方向每个方向向两端延伸统计连续同色棋子数。不需要每次全盘扫描否则时间复杂度和代码可读性都差一截。checkWin(row, col, player) { const board this.data.board; const directions [ [1, 0], // 水平 [0, 1], // 垂直 [1, 1], // 主对角线 [1, -1] // 副对角线 ]; for (let [dx, dy] of directions) { let count 1; // 正方向延伸 for (let step 1; step 5; step) { const nr row dx * step; const nc col dy * step; if (nr 0 || nr 14 || nc 0 || nc 14 || board[nr][nc] ! player) break; count; } // 反方向延伸 for (let step 1; step 5; step) { const nr row - dx * step; const nc col - dy * step; if (nr 0 || nr 14 || nc 0 || nc 14 || board[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }这里有个常被忽略的性能与逻辑细节count 5而不是count 5。因为棋盘上可能已经出现「活四」或「冲五」的局面一子落下后连续同色棋数可能达到 6 甚至更长判 5会漏判已经成五但两端还有同色延伸的情况。另一个重点是方向向量的正负组合[1, -1]表示向右上延伸反方向就是[-1, 1]向左下四个方向覆盖完整不重不漏。边界检查放在访问board[nr][nc]之前顺序不能反。JS 数组越界访问返回undefinedundefined ! player能兜住一部分但row dx * step出现负数时board[-1]在 JS 里不会报错只会返回undefined逻辑上能跳过代码风格上不要依赖这种隐式行为显式判断更稳。3.3 SetData 更新棋盘与切换回合落子后要做的三件事更新board二维数组、切换当前玩家、调用setData重绘。常见误用是直接在setData里传一个深层副本导致小程序在 diff 时性能下降。15 路棋盘只有 225 个格子任何 diff 策略都扛得住不需要做 Immutable 优化但代码结构上要清晰placePiece(row, col) { const board this.data.board; const currentPlayer this.data.currentPlayer; board[row][col] currentPlayer; this.setData({ board: board, currentPlayer: currentPlayer 1 ? 2 : 1 }); // 用 canvas 直接在原棋盘上画一颗棋子避免整盘重绘 this.drawPiece(row, col, currentPlayer); if (this.checkWin(row, col, currentPlayer)) { wx.showModal({ title: 对局结束, content: 玩家 ${currentPlayer 1 ? 黑棋 : 白棋} 获胜, showCancel: false }); return; } const isDraw board.every(row row.every(cell cell ! 0)); if (isDraw) { wx.showModal({ title: 对局结束, content: 平局, showCancel: false }); } }drawPiece用ctx.arc画实心圆drawPiece(row, col, player) { const ctx this.data.ctx; const margin this.data.margin; const cellSize this.data.cellSize; const centerX margin col * cellSize; const centerY margin row * cellSize; const radius cellSize * 0.42; ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.fillStyle player 1 ? #1a1a1a : #f5f5f5; ctx.fill(); ctx.strokeStyle player 1 ? #000 : #ccc; ctx.lineWidth 1; ctx.stroke(); }radius取cellSize * 0.42而不是 0.5是为了让相邻棋子之间留一条细缝视觉上接近真木棋子挨在一起的间隙。0.5 会让棋子边界粘连在浅色棋盘上糊成一片。新手常把radius写cellSize / 2一眼看不出问题下到中盘棋形密集时就显得脏。参数取值说明cellSize * 0.4221 左右棋子半径留出间隙boardSize15棋盘点数15 路为一对一经典规格margin24边距过大浪费画布过小棋子出界lineWidth11 在 2x 屏上等于 2 物理像素清晰不糊4. 悔棋、重置与简单对弈 AI 扩展4.1 用历史栈实现悔棋单机五子棋的悔棋实现本质是「撤销上一步」。一个数组做历史栈每次落子push坐标和玩家悔棋时pop把对应位置置 0再用ctx.clearRect只擦那一格重画棋盘。不需要整盘重绘否则悔棋后棋盘会闪烁。undoMove() { const history this.data.history; if (history.length 0) return; const last history.pop(); const { row, col, player } last; const board this.data.board; board[row][col] 0; this.setData({ board: board, history: history, currentPlayer: player }); const ctx this.data.ctx; const margin this.data.margin; const cellSize this.data.cellSize; ctx.fillStyle #deB887; ctx.fillRect( margin col * cellSize - cellSize / 2, margin row * cellSize - cellSize / 2, cellSize, cellSize ); // 重新绘制该位置的网格线 this.redrawCellLines(row, col); }currentPlayer恢复为被撤销那一步的玩家这样连续悔棋双方可以交替回退。redrawCellLines里要重新画十字线否则擦除棋子后网格线也消失了。这一步很容易漏多数悔棋 Bug 都出在这——棋子擦掉了格线也擦掉了棋盘越悔越花。4.2 权值 AI一个能下赢新手的思路原工程是双人对战如果你想让这个单机项目变成人机对战一个合适的基础实现是「权值打分」遍历每个空位计算在该位置落子后对黑棋和白棋的棋型贡献值取最高分落子。不需要做蒙特卡洛对新手练习和演示足够。evaluatePoint(board, row, col, player) { const directions [[1, 0], [0, 1], [1, 1], [1, -1]]; let score 0; for (let [dx, dy] of directions) { let count 1; let openEnds 0; for (let step 1; step 5; step) { const nr row dx * step; const nc col dy * step; if (nr 0 || nr 14 || nc 0 || nc 14 || board[nr][nc] ! player) { if (nr 0 nr 14 nc 0 nc 14 board[nr][nc] 0) openEnds; break; } count; } for (let step 1; step 5; step) { const nr row - dx * step; const nc col - dy * step; if (nr 0 || nr 14 || nc 0 || nc 14 || board[nr][nc] ! player) { if (nr 0 nr 14 nc 0 nc 14 board[nr][nc] 0) openEnds; break; } count; } score getScore(count, openEnds); } return score; }权值表getScore参考连续棋子数两端开放情况分值5任意1000004两端开放100004一端开放10003两端开放10003一端开放1002两端开放1002一端开放101两端开放10AI 决策时对每个空位分别计算「进攻分AI 落子」和「防守分玩家落子在该位置」取两者最大值作为该位置综合分。通常防守分权重乘以 1.1 左右AI 会偏向堵玩家而不是盲目冲自己的进攻线。这个调整只靠一个系数完成不需要引入额外复杂度。5. 真机验证技巧与常见坑位排查小程序 Canvas 有个诡异现象开发者工具里运行正常真机上棋盘模糊、棋子错位、点击无响应。大部分原因集中在三个方面DPR 适配缺失、Canvas 节点获取时机、事件坐标坐标系混用。DPR 适配缺失表现为真机棋盘边缘模糊开发者工具因为是桌面浏览器pixelRatio通常是 1看不出异常真机pixelRatio在 2 到 3 之间不做canvas.width cssWidth * dpr就会出现像素采样不足。验证方法很简单在initCanvas里加一行console.log(dpr, canvas.width, canvas.height)真机调试时看输出canvas.width小于cssWidth * 2就说明适配代码没生效。Canvas 节点获取时机踩坑的典型场景是把query.select(#boardCanvas).exec()放在onLoad里执行。小程序的onLoad只保证页面逻辑层就绪并不意味着 WXML 已经渲染完成。正确做法是放在onReady里或者用wx.nextTick包一层。如果exec回调里res[0]是undefined先去看是不是放在onLoad。事件坐标混用的问题在切换新旧 Canvas 接口时最容易出现。旧版wx.createCanvasContext的bindtap事件拿e.touches[0]新版 Canvas 2D 的bindtap拿e.detail二者坐标系基准也不同。页面里多个 Canvas 时务必确认e.detail属于当前 Canvas 节点。我的排查顺序是先打印e.detail再换算坐标最后才看落子结果。还有一个验证对局逻辑是否健壮的小技巧绕开触摸事件直接在onReady后手动调用placePiece的几个边界坐标。例如placePiece(0, 0, 1)、placePiece(14, 14, 2)、placePiece(7, 7, 1)如果能正常绘制且不报错说明棋盘的边界索引、坐标换算、胜负扫描在四角极限情况下都没问题。自动模拟「四方向五连」胜局的脚本可以写一个简单循环连下 5 子再调checkWin看是否返回trueconst testSequence [[3, 3], [10, 10], [4, 3], [10, 11], [5, 3], [10, 12], [6, 3], [10, 13], [7, 3]]; testSequence.forEach(([r, c], idx) { const player idx % 2 0 ? 1 : 2; placePiece(r, c, player); if (idx 8) { console.log(水平五连判定:, checkWin(7, 3, 1)); } });这段脚本一次验证两件事第 1、3、5、7、9 手连成水平五黑棋胜负判定应返回true白棋在另一条竖线上的阻断行为不会影响黑棋的获胜路径。跑完看控制台输出如果是true核心逻辑就过关了。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →