微信小游戏五子棋开发实战:Canvas渲染、触摸交互与AI算法

发布时间:2026/9/14 14:31:20
微信小游戏五子棋开发实战:Canvas渲染、触摸交互与AI算法 简介一份微信小游戏的五子棋单机对战源码适合刚接触小游戏开发的新手与前端学习者用来理解微信小游戏运行机制和JavaScript基础逻辑。整个压缩包只有4KB共6个文件包括3个js文件、2个json文件和1个md说明文档js文件承载棋盘绘制、落子交互与胜负判断等核心逻辑json负责项目配置与游戏参数md文档简要说明工程结构和运行方式结构简洁明了便于快速阅读和二次修改。通过这份源码能够学到小游戏的初始化方式、按钮或画布事件处理、二维数组状态管理等实用知识点代码量精简逻辑集中非常适合逐行研读和模仿可快速搭建一个能运行的Demo用于课程设计或兴趣练习。上线以来已有1960人学习下载对想入门微信小游戏开发或需要轻量级参考项目的开发者来说是一份很实用的学习材料。1. 微信小游戏里的五子棋为什么不能直接搬网页代码很多人第一次做微信小游戏第一反应是把自己博客上的 HTML5 五子棋换个壳塞进去。等真在微信开发者工具里跑起来才发现不渲染、触摸没反应、屏幕适配乱成一团。原因倒不复杂微信小游戏没有 DOM 和 BOM只有一个极简的 Canvas 接口。网页里那套document.getElementById、addEventListener、.style.width全是 undefined。所谓「五子棋游戏-微信小游戏项目源码」真正的价值不仅仅是那几盘对弈逻辑而是把五子棋这套规则完整搬到微信运行时里的工程骨架。这篇按我自己的实践路径来讲先拆运行架构和渲染选型再给出一套能抄的工程骨架然后逐个解决棋盘坐标换算、触摸事件、胜负判定这些绕不开的硬骨头最后把五子棋 AI 的常见写法和调参空间讲透。适合已经会点 JavaScript、写过简单网页游戏、但第一次接触微信小游戏的同学。前半部分偏原理后半部分全是代码和参数可以直接照着改。2. 微信小游戏运行环境与五子棋项目的工程骨架2.1 小游戏没有 DOMCanvas 是唯一的绘图出口微信小游戏在底层是一个特制的 JavaScript 运行时它运行在 WebView 的纯逻辑层但移除了window、document、navigator等浏览器对象。开发者唯一能用的绘图接口是wx.createCanvas()返回的 Canvas 对象以及它身上的 2d 上下文。这决定了五子棋的渲染方式必须自己管理画布尺寸自己配、坐标系统自己算、触摸事件自己绑。一个常见误区是拿着网页版的坐标clientX去直接落子。小游戏的触摸事件对象里touches[0].clientX和clientY是相对屏幕左上角的值而你的 Canvas 往往做了安全区适配或宽度缩放。如果一个棋盘画在了非全屏区域就必须把触摸坐标先换算到棋盘归一化坐标再映射到格子坐标。这段转换逻辑如果写不对会呈现出「点哪里都错半个格」的诡异效果。2.2 工程目录与模块拆分的推荐姿势2.2.1 五子棋的代码结构划分我习惯把一个微信小游戏五子棋项目按下面这样组织project.config.json game.js // 入口创建 Canvas启动主循环 game.json // 小游戏配置 js/ config.js // 全局配置棋盘大小、配色、AI 深度 board.js // 棋盘数据模型落子与胜负检测 renderer.js // 绘制棋盘、棋子、光标、提示 ai.js // AI 落子逻辑评估函数 Alpha-Beta touch.js // 触摸坐标换算与事件绑定 main.js // 游戏流程控制回合管理game.js是微信要求的固定入口里面做的事很有限创建主画布、实例化Main或Game对象、启动第一帧。剩余所有逻辑按照数据层、渲染层、AI 层、输入层拆分到各模块。这么拆的好处是棋盘数据和绘制完全解耦AI 不需要知道棋子画在哪只关心二维数组上的 0/1/2渲染层也不维护任何游戏状态只负责把当前局面画出来。2.2.2 用 module.exports 而不是 ES Module微信小游戏对 ES Module 的支持一直比较暧昧。老版本基础库只认 CommonJSrequire和module.exports是保底选择。我建议项目直接以 CommonJS 风格写// board.js var BOARD_SIZE 15; function createBoard(size) { var board []; for (var i 0; i size; i) { board[i] []; for (var j 0; j size; j) { board[i][j] 0; // 0空 1黑 2白 } } return board; } module.exports { BOARD_SIZE: BOARD_SIZE, createBoard: createBoard };这段代码做的事生成一个size * size的二维数组每个格子初始为 0。createBoard独立成函数是为了方便后续实现「悔棋」「重开」时重新初始化。模块导出的方式也统一了内部接口不管require放在哪个文件里拿到的都是同一个引用。在小游戏环境中这种写法比import更不容易在构建环节出问题。2.3 Canvas 的适配策略与像素比处理2.3.1 获取屏幕尺寸的 API 和 1px 模糊问题微信小游戏里拿屏幕尺寸的方式是wx.getSystemInfoSync()。这个接口返回windowWidth和windowHeight单位是逻辑像素。但 Canvas 的实际绘制精度和物理像素有关如果直接把逻辑像素当成宽高去canvas.width windowWidth在高分屏上会看到明显的锯齿和模糊。规避方式是把画布的像素尺寸乘以pixelRatio然后用 CSS 样式把显示尺寸控制在逻辑像素上// main.js 中初始化画布 var sysInfo wx.getSystemInfoSync(); var pixelRatio sysInfo.pixelRatio; var canvas wx.createCanvas(); canvas.width sysInfo.windowWidth * pixelRatio; canvas.height sysInfo.windowHeight * pixelRatio; var ctx canvas.getContext(2d); ctx.scale(pixelRatio, pixelRatio);逻辑说明第一行和第二行拿出设备信息和像素比第三行创建画布第四和第五行设置画布的实际像素容量此时宽高已经是物理像素第六行拿到绘图上下文最后一行调用scale把坐标系单位从物理像素映射回逻辑像素。这样后面所有绘制代码都可以直接用逻辑像素坐标不用每次换算。如果不做这一步在 iPhone 这类高分屏上棋盘的线会发虚棋子边缘像被锯齿啃过。做小游戏第一眼质感很重要。2.3.2 棋盘居中与边距的选择我一般不在全屏铺满棋盘而是留出上下两个区域上面显示当前回合和比分下面留一点手指操作缓冲。棋盘绘制从marginX和marginY开始把网格区域控制在屏幕中央偏下。config.js里建议这样写module.exports { BOARD_SIZE: 15, CELL_SIZE: 24, // 像素/格 MARGIN_TOP: 80, MARGIN_SIDE: 15, PIECE_RADIUS: 10, AI_DEPTH: 2 };CELL_SIZE是每个格子占的逻辑像素宽MARGIN_TOP是给顶部信息栏留的位置MARGIN_SIDE是棋盘到左右屏幕的安全距离。选择 24px 的格子是因为在小屏手机上 15 条线乘以 24 等于 336px加上两侧留白不超过 375px 的常见宽度单手操作时拇指覆盖区域比较舒服。AI 深度默认 2是因为后面要讲的评估函数在纯 JavaScript 里跑深度太深会导致每步思考时间超过 1 秒交互体验很差。3. 棋盘绘制、坐标换算与五子棋落子逻辑3.1 两种坐标系之间的映射关系3.1.1 触摸坐标到格子坐标的换算公式落子判定的第一步是把触摸点坐标映射成棋盘上的行列索引。假设棋盘左上角在屏幕上的逻辑坐标是(MARGIN_SIDE, MARGIN_TOP)格子宽为cellSize那么触摸事件的clientX和clientY对应的格子索引为function getGridByTouch(touch, canvasOrigin, cellSize) { var gridX Math.floor((touch.clientX - canvasOrigin.x) / cellSize); var gridY Math.floor((touch.clientY - canvasOrigin.y) / cellSize); return { x: gridX, y: gridY }; }参数说明touch.clientX是小游戏触摸事件里的横坐标canvasOrigin.x是棋盘左上角的横坐标减去它得到相对棋盘左上角的偏移量再除以格子宽取整后就是列数。Math.floor的作用是向下取整保证落在格子左边缘附近的点归入当前格而不是前一格。这里有个隐含前提触摸坐标的单位和画布逻辑坐标是一致的。前面已经用ctx.scale(pixelRatio, pixelRatio)统一了坐标系所以这个公式才能直接用。3.1.2 落子时还要做一次边界校验与已有棋子判断拿到gridX和gridY后不能直接落子。先判断索引是否越界再判断该位置是否已有棋子最后才更新数据模型function placePiece(board, gridX, gridY, player) { if (gridX 0 || gridX board.length || gridY 0 || gridY board[0].length) { return false; } if (board[gridY][gridX] ! 0) { return false; } board[gridY][gridX] player; // player 1黑 2白 return true; }这段代码逻辑分三层。第一层是边界保护防止gridX或gridY超出 0 到 14 的范围第二层是占用检测检查目标格子是否已被占据第三层才是赋值。注意这里的写法是board[gridY][gridX]因为二维数组的外层是行y内层是列x别和(x, y)的直觉搞反。很多五子棋源码的 bug 都出在这个行列顺序上写的时候多留个心眼。3.2 棋盘绘制的最小指令集3.2.1 画网格、星位和棋子的 Canvas 代码微信小游戏的 Canvas 2D 接口和浏览器很接近但也有细微差别比如某些版本不支持ctx.roundRect。我按最保守的做法来绘制// renderer.js function drawBoard(ctx, config) { var size config.BOARD_SIZE; var cellSize config.CELL_SIZE; var startX config.MARGIN_SIDE; var startY config.MARGIN_TOP; var endX startX cellSize * (size - 1); var endY startY cellSize * (size - 1); // 背景 ctx.fillStyle #DEB887; ctx.fillRect(startX - 8, startY - 8, cellSize * (size - 1) 16, cellSize * (size - 1) 16); // 网格线 ctx.strokeStyle #000000; ctx.lineWidth 1; for (var i 0; i size; i) { ctx.beginPath(); ctx.moveTo(startX, startY i * cellSize); ctx.lineTo(endX, startY i * cellSize); ctx.stroke(); ctx.beginPath(); ctx.moveTo(startX i * cellSize, startY); ctx.lineTo(startX i * cellSize, endY); ctx.stroke(); } }绘制顺序很关键先画背景矩形再画 15 横 15 竖的网格线。背景色用#DEB887Burlywood模拟木质效果比纯白有辨识度。fillRect多出的 8px 是让棋子落在边缘时不会被截断。画网格线时横线和竖线分别用moveTo定位起点、lineTo定位终点stroke才真正画出来。这里没有用ctx.save()和restore()因为绘制函数之间如果状态污染排查起来会很麻烦。3.2.2 棋子的径向渐变绘制棋子如果只是一个纯色圆观感非常塑料。我一般用径向渐变来模拟立体感function drawPiece(ctx, x, y, radius, isBlack) { ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); var gradient ctx.createRadialGradient( x - radius / 3, y - radius / 3, radius / 4, x, y, radius ); if (isBlack) { gradient.addColorStop(0, #636363); gradient.addColorStop(1, #0a0a0a); } else { gradient.addColorStop(0, #ffffff); gradient.addColorStop(1, #d0d0d0); } ctx.fillStyle gradient; ctx.fill(); }参数含义(x, y)是圆心坐标由格子索引换算而来radius是棋子的半径isBlack决定颜色渐变方向。createRadialGradient的第一个圆是光源位置第二个圆是填充范围这样做出的棋子高光点在左上方视觉上是「被光从左上角照射」。黑白棋子的灰度端点值是人眼看着比较舒服的区间不至于过曝或死黑。3.2.3 绘制最后一手标记对局中玩家很需要知道自己刚落在哪。实现方式是记录lastMove的坐标在对应位置画一个小红点function drawLastMove(ctx, lastMove, cellSize, config) { if (!lastMove) return; var x config.MARGIN_SIDE lastMove.x * cellSize; var y config.MARGIN_TOP lastMove.y * cellSize; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fillStyle #FF4500; ctx.fill(); }这个红点不是棋子只是标记所以半径明显小于PIECE_RADIUS。放在棋子上面绘制不影响棋盘数据的正确性。如果不想让红点干扰观感也可以在drawPiece之前画这样红点会被棋子盖掉只留下一个颜色更深的圆心阴影。3.3 胜负判定八方向扫描的两种写法对比3.3.1 递归 Flood Fill 的写法与坑五子棋胜负判定有很多种写法。最常见的除了逐方向扫描还有一种递归 Flood Fill从落子点出发向八个方向扩散每遇到同色棋子就继续深入。function checkWin(board, row, col, player) { var directions [[1,0],[0,1],[1,1],[1,-1]]; for (var d 0; d directions.length; d) { var count 1; count countDirection(board, row, col, directions[d][0], directions[d][1], player); count countDirection(board, row, col, -directions[d][0], -directions[d][1], player); if (count 5) return true; } return false; } function countDirection(board, row, col, dx, dy, player) { var count 0; var r row dx; var c col dy; while (r 0 r board.length c 0 c board[0].length board[r][c] player) { count; r dx; c dy; } return count; }这段设计很妙只需要检测 4 个正方向因为每个方向的相反方向合并后覆盖了全部 8 个方向。以[1,0]为例正方向是向下反方向是向上两者棋子数加 1当前子自己就是竖直方向的总连续数。递归在这里是隐式的while循环替代了函数自调用避免了深度过大带来的调用栈风险。每落一子只做一次checkWin时间复杂度 O(棋盘边长)对 15 路棋盘完全不是问题。3.3.2 为什么推荐用「扫描同色段」而非「五子模板匹配」另一种判定方式是遍历棋盘上所有横向、纵向、斜向的连续五格看是不是五个同色。这个做法直观但要扫描的量是15 * 15 * 4 * 5每次落子都要全盘遍历而且还要额外处理边界。相比之下8 方向扫描只从落子点出发局部计算不触碰无关区域。还有个更实际的原因模板匹配难以扩展。如果你以后想加入「活三」「冲四」的提示模板匹配就得硬编码几十种排列组合而方向扫描配合后面要讲的评估函数可以天然复用同一套countDirection逻辑代码不会膨胀。3.4 触摸事件的绑定与防误触策略3.4.1 用 wx.onTouchStart 而不是 canvas.addEventListener小游戏的触摸事件可以直接挂在画布上但更推荐的入口是wx.onTouchStart。区别在于canvas.addEventListener只在 Canvas 渲染层内部生效而wx.onTouchStart是全局触控回调。如果画布被 UI 组件盖住一部分前者可能漏响应。wx.onTouchStart(function(e) { if (gameOver) return; var touch e.touches[0]; var grid getGridByTouch(touch, canvasOrigin, cellSize); if (!placePiece(board, grid.x, grid.y, currentPlayer)) return; lastMove { x: grid.x, y: grid.y }; if (checkWin(board, grid.y, grid.x, currentPlayer)) { gameOver true; showResult(currentPlayer); } else { switchPlayer(); } });回调逻辑顺序很重要先判断游戏是否结束再换算坐标然后尝试落子。placePiece返回false的三种情况——越界、已有子、坐标非法——都直接return防止空落子。落子成功后马上更新lastMove、检测胜负、切换玩家。注意checkWin的参数顺序是(row, col)而getGridByTouch返回的是(x, y)这里要做一次反转。这种参数顺序错位是五子棋源码最常见的隐蔽 bug建议在函数签名里直接写成board[row][col]的语义外部调用时就传(grid.y, grid.x)一眼能看清。注意wx.onTouchStart注册的是全局回调。如果页面还有其他按钮记得在回调里先判断触摸点是否在控件区域内否则会出现「点按钮同时落子」的灵异现象。4. 五子棋 AI 算法评估函数、极小化极大与落子优化4.1 AI 的选型为什么不用随机落子五子棋的 AI 是一个典型的零和博弈问题。最基础的版本是「扫描空位找能形成五连的位置」但这只能做简单防守。稍微像样一点的 AI 需要完成两件事第一评估某个位置落子后的局势优劣第二在决策树上向前搜若干步选一条预期收益最高的路径。随机落子或者「只看眼前一步」的 AI 在玩家眼里就是智障三分钟内被揍出心理阴影。这三个层次对应三种代码风格随机下棋、贪心下棋、搜索下棋。随机版是给完全不懂博弈论的人练手用的贪心版只评估当前局面找到一个「此刻最佳」的空位搜索版则深入推演后续几步。我做的是搜索版用极小化极大配合 Alpha-Beta 剪枝这也是五子棋 AI 题目的标准解法。4.2 评估函数把棋盘局面变成一个数值4.2.1 评分表的设计思路与参数评估函数是 AI 的「审美标准」。它把棋盘的某个局部形状映射成一个分数AI 希望我方分数高、对方分数低。最简单的做法是枚举棋盘上所有的行、列、斜线上的连续同色段按长度打分棋形我方分数对方分数活四10000080000冲四5000040000活三100008000眠三1000800活二500400眠二10080这里的关键在于分值差距设计。活四和冲四的差距是两倍逼着 AI 优先堵死对手的活四活三和眠三的差距也是两倍引导 AI 在加强自己活三和破坏对手活三之间做选择。如果两边分差太小AI 会显得「优柔寡断」甚至走出毫无攻击性的废棋。4.2.2 评分函数的代码实现我常用的评分函数是按方向扫描所有连续同色段同时记录两端是否为空function evaluatePoint(board, row, col, player) { var score 0; var directions [[1,0],[0,1],[1,1],[1,-1]]; for (var d 0; d directions.length; d) { var dx directions[d][0], dy directions[d][1]; var count 1; var openEnds 0; var r row dx, c col dy; while (inBound(r, c) board[r][c] player) { count; r dx; c dy; } if (inBound(r, c) board[r][c] 0) openEnds; r row - dx; c col - dy; while (inBound(r, c) board[r][c] player) { count; r - dx; c - dy; } if (inBound(r, c) board[r][c] 0) openEnds; if (count 5) score 100000; else if (count 4 openEnds 2) score 10000; else if (count 4 openEnds 1) score 5000; else if (count 3 openEnds 2) score 1000; else if (count 3 openEnds 1) score 500; else if (count 2 openEnds 2) score 200; } return score; }openEnds记录的是该连续段的两个端点是否空闲。一个「活四」意味着两端都空对手怎么堵都能连成五而「冲四」只有一端空另一端被堵对手堵住唯一缺口就无事发生。这里给count 4 openEnds 1只打 5000 分比活四低一倍是符合棋理的活四必胜冲四需要看对手脸色。实际经验里AI 经常「一子双用」既能进攻又能防守单点评估很难完全体现这种隐蔽价值但作为搜索的叶子节点评估已经够用。4.3 极小化极大 Alpha-Beta 剪枝的落地4.3.1 候选点挑选只搜有棋子的邻域全盘 225 个空位全搜到深度一多就爆炸。一个工程优化是只把距离已有棋子 1 格内的空位作为候选点。这个启发式对五子棋非常有效因为没人会把棋下到天涯海角。function getCandidateMoves(board) { var moves []; for (var r 0; r BOARD_SIZE; r) { for (var c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) continue; if (hasNeighbor(board, r, c, 1)) { moves.push({ x: c, y: r }); } } } return moves; } function hasNeighbor(board, row, col, dist) { for (var dr -dist; dr dist; dr) { for (var dc -dist; dc dist; dc) { if (dr 0 dc 0) continue; var nr row dr, nc col dc; if (nr 0 nr BOARD_SIZE nc 0 nc BOARD_SIZE board[nr][nc] ! 0) { return true; } } } return false; }候选点的数量直接决定搜索耗时。一颗棋子周围有 24 个邻域格两颗棋子大约产生 40-60 个候选点。深度 2 的搜索最坏情况要评估候选点1 * 候选点2种组合也就是几千次评估函数调用在 JavaScript 里完全可以在 100ms 内跑完。如果性能还是不够可以先按评估函数打分排序只取前 10 个候选点再进搜索。4.3.2 Alpha-Beta 递归的完整实现var MAX_SCORE 1000000; function alphaBeta(board, depth, alpha, beta, isMaximizing, aiPlayer, humanPlayer) { if (depth 0) { return evaluateBoard(board, aiPlayer) - evaluateBoard(board, humanPlayer); } var moves getCandidateMoves(board); if (moves.length 0) return 0; if (isMaximizing) { var maxEval -Infinity; for (var i 0; i moves.length; i) { board[moves[i].y][moves[i].x] aiPlayer; var evalVal alphaBeta(board, depth - 1, alpha, beta, false, aiPlayer, humanPlayer); board[moves[i].y][moves[i].x] 0; maxEval Math.max(maxEval, evalVal); alpha Math.max(alpha, evalVal); if (beta alpha) break; } return maxEval; } else { var minEval Infinity; for (var i 0; i moves.length; i) { board[moves[i].y][moves[i].x] humanPlayer; var evalVal alphaBeta(board, depth - 1, alpha, beta, true, aiPlayer, humanPlayer); board[moves[i].y][moves[i].x] 0; minEval Math.min(minEval, evalVal); beta Math.min(beta, evalVal); if (beta alpha) break; } return minEval; } }核心逻辑在每一层递归里isMaximizing表示当前层是 AI 决策想最大化局面分数还是玩家决策想最小化 AI 的分数。alpha是到目前为止 AI 能保证的最低分beta是玩家能保证的最高分。一旦beta alpha说明当前分支不可能被对手选择直接剪掉不再深入搜索。这段代码的关键操作是「落子后立即递归递归完立即恢复棋盘局面」——这个复位步骤是博弈树搜索最容易遗漏的。漏了复位搜索完一个分支后棋盘还是带着临时棋子的状态下一个分支的数据全被污染AI 会走出荒谬的棋。4.3.3 首层调用与返回值处理外层调用alphaBeta时要从候选点里挑让 AI 分数最大的那个落子点而不是只拿分数function aiMove(board, aiPlayer, humanPlayer, depth) { var moves getCandidateMoves(board); if (moves.length 0) return null; var bestMove null; var bestScore -Infinity; for (var i 0; i moves.length; i) { board[moves[i].y][moves[i].x] aiPlayer; var score alphaBeta(board, depth - 1, -Infinity, Infinity, false, aiPlayer, humanPlayer); board[moves[i].y][moves[i].x] 0; if (score bestScore) { bestScore score; bestMove moves[i]; } } return bestMove; }注意这里搜索深度传的是depth - 1。因为 aiMove 自己已经预先落了一子alphaBeta里从对手视角开始搜所以game.js里如果调用aiMove(board, aiPlayer, humanPlayer, 3)实际做了 AI 一步 对手一层 AI 一层的三层搜索。如果传depth而不是depth - 1搜索深度会比预期多一层AI 的思考时间成倍增长但在棋力上并没有明显提升。这个「差一」是 Alpha-Beta 实现最常见的困惑点。4.4 AI 响应时间与体验的平衡4.4.1 深度选择的经验值与小屏设备从实测经验看15 路五子棋候选点控制在 12 个以内时深度 4 大约需要 200-300ms深度 6 会跑到几秒。微信小游戏里玩家等待 AI 落子的耐心大概在 1 秒以内。再久就会认为是卡死或死机。所以我的建议是设备档次推荐深度预期响应中低端安卓2 100ms主流旗舰3100ms-300ms高端旗舰4300ms-800ms想要更精细的控制可以在config.js里加一个AI_TIME_LIMIT参数递归搜索过程中检查Date.now() - startTime是否超时超时就提前截断返回当前已搜索到的最佳走法。这个叫「时间片搜索」是棋类 AI 的标准做法。注意搜索过程和主线程是同步的长时间占用会阻塞渲染。如果 AI 响应时间超过 200ms可以在进入 AI 计算前先调用wx.showLoading({ title: 思考中 })计算完成后再wx.hideLoading()给玩家一个明确的反馈而不至于产生「死机」的错觉。5. 源码落地时的常见坑位与联机扩展建议5.1 触摸穿透UI 控件和棋盘落子的热区重叠小游戏的wx.onTouchStart是全局的当你画了一个「重新开始」按钮在棋盘上方按钮区域也属于触摸事件的一部分。如果按钮x、y、width、height与棋盘热区重叠玩家点按钮时会同时触发落子逻辑出现「刚点重开棋盘上莫名其妙多了一颗子」的灵异事件。解决办法是在触摸回调里加一层热区判断var restartBtn { x: 20, y: 20, width: 120, height: 44 }; wx.onTouchStart(function(e) { var touch e.touches[0]; if (isInRect(touch.clientX, touch.clientY, restartBtn)) { resetGame(); return; } // 后续落子逻辑 }); function isInRect(x, y, rect) { return x rect.x x rect.x rect.width y rect.y y rect.y rect.height; }这段代码的核心是把按钮判定放在落子逻辑之前。resetGame()先执行然后return彻底阻断后续的落子流程。isInRect是个纯函数不依赖任何全局状态方便单独测试。如果以后加「悔棋」按钮也用同样的模式扩展。5.2 棋盘数据与渲染同步为什么最后一手经常消失很多人在做「悔棋」功能时发现棋子消失后重叠位置的棋子会被后面的棋子盖掉或者最后一手标记消失后没有重绘。根本原因是棋盘数据模型和渲染层之间的同步失效。正确做法是每次操作后统一走一遍「清空画布 → 重画棋盘 → 重画所有棋子 → 重画最后一手标记」的流程而不是只重画变化的那个像素区域function render(ctx, state) { ctx.clearRect(0, 0, state.width, state.height); drawBoard(ctx, state.config); for (var r 0; r state.board.length; r) { for (var c 0; c state.board[r].length; c) { if (state.board[r][c] ! 0) { var x state.config.MARGIN_SIDE c * state.config.CELL_SIZE; var y state.config.MARGIN_TOP r * state.config.CELL_SIZE; drawPiece(ctx, x, y, state.config.PIECE_RADIUS, state.board[r][c] 1); } } } if (state.lastMove) { drawLastMove(ctx, state.lastMove, state.config.CELL_SIZE, state.config); } }clearRect的参数用的是逻辑像素坐标和前面ctx.scale(pixelRatio, pixelRatio)配合不会出现清除不干净的问题。这里的drawPiece每次都重新画虽然看起来浪费性能但 15x15 的棋盘最多 225 颗棋子Canvas 每帧绘制几百个圆没有任何压力。5.3 本地对战能玩联机版从哪开始改如果后续要做联机对战源码改动的边界习惯上这样规划board.js不用动。双方的落子都只是placePiece数据模型是纯逻辑的。touch.js从「直接落子」改成「发送消息到服务端等服务器广播后再本地执行落子」。新增network.js封装微信小游戏的wx.connectSocket处理onMessage回调同步对方棋步。AI 模块联机版根本不需要直接删除。服务端推荐 WebSocket客户端在game.js里wx.connectSocket({ url: wss://your-server.com })。收到onMessage后用 JSON 协议解析落子坐标和操作类型再回灌到board.js。这里的核心是所有玩家操作都经过服务器校验后再修改本地棋盘防止外挂直接写board数组作弊。5.4 卡在加载页面或黑屏的排查顺序微信开发者工具里最常见的问题之一是「真机预览白屏工具里能跑」。先检查game.json里是否配置了deviceOrientation: portrait部分安卓设备在横竖屏切换时会导致 Canvas 尺寸错乱。再检查wx.createCanvas()的调用时机必须在 App 启动早期完成不要在异步回调里才创建画布。最后确认ctx.scale(pixelRatio, pixelRatio)之后后续绘制有没有误再乘一次pixelRatio——双重缩放会把画面推出屏幕外露出一片白底。这三个问题依次排查能解决 80% 的白屏症状。本文还有配套的精品资源点击获取