微信小游戏五子棋开发实战:Canvas渲染、触摸交互与AI算法
2026/9/14 14:31:19 网站建设 项目流程

简介:一份微信小游戏的五子棋单机对战源码,适合刚接触小游戏开发的新手与前端学习者,用来理解微信小游戏运行机制和JavaScript基础逻辑。整个压缩包只有4KB,共6个文件,包括3个js文件、2个json文件和1个md说明文档:js文件承载棋盘绘制、落子交互与胜负判断等核心逻辑,json负责项目配置与游戏参数,md文档简要说明工程结构和运行方式,结构简洁明了,便于快速阅读和二次修改。通过这份源码,能够学到小游戏的初始化方式、按钮或画布事件处理、二维数组状态管理等实用知识点;代码量精简,逻辑集中,非常适合逐行研读和模仿,可快速搭建一个能运行的Demo用于课程设计或兴趣练习。上线以来已有1960人学习下载,对想入门微信小游戏开发或需要轻量级参考项目的开发者来说,是一份很实用的学习材料。

1. 微信小游戏里的五子棋,为什么不能直接搬网页代码

很多人第一次做微信小游戏,第一反应是把自己博客上的 HTML5 五子棋换个壳塞进去。等真在微信开发者工具里跑起来,才发现不渲染、触摸没反应、屏幕适配乱成一团。原因倒不复杂:微信小游戏没有 DOM 和 BOM,只有一个极简的 Canvas 接口。网页里那套document.getElementByIdaddEventListener.style.width全是 undefined。所谓「五子棋游戏-微信小游戏项目源码」,真正的价值不仅仅是那几盘对弈逻辑,而是把五子棋这套规则完整搬到微信运行时里的工程骨架。

这篇按我自己的实践路径来讲:先拆运行架构和渲染选型,再给出一套能抄的工程骨架;然后逐个解决棋盘坐标换算、触摸事件、胜负判定这些绕不开的硬骨头;最后把五子棋 AI 的常见写法和调参空间讲透。适合已经会点 JavaScript、写过简单网页游戏、但第一次接触微信小游戏的同学。前半部分偏原理,后半部分全是代码和参数,可以直接照着改。

2. 微信小游戏运行环境与五子棋项目的工程骨架

2.1 小游戏没有 DOM,Canvas 是唯一的绘图出口

微信小游戏在底层是一个特制的 JavaScript 运行时,它运行在 WebView 的纯逻辑层,但移除了windowdocumentnavigator等浏览器对象。开发者唯一能用的绘图接口是wx.createCanvas()返回的 Canvas 对象,以及它身上的 2d 上下文。这决定了五子棋的渲染方式必须自己管理:画布尺寸自己配、坐标系统自己算、触摸事件自己绑。

一个常见误区是拿着网页版的坐标clientX去直接落子。小游戏的触摸事件对象里,touches[0].clientXclientY是相对屏幕左上角的值,而你的 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是微信要求的固定入口,里面做的事很有限:创建主画布、实例化MainGame对象、启动第一帧。剩余所有逻辑按照数据层、渲染层、AI 层、输入层拆分到各模块。这么拆的好处是棋盘数据和绘制完全解耦,AI 不需要知道棋子画在哪,只关心二维数组上的 0/1/2;渲染层也不维护任何游戏状态,只负责把当前局面画出来。

2.2.2 用 module.exports 而不是 ES Module

微信小游戏对 ES Module 的支持一直比较暧昧。老版本基础库只认 CommonJS,requiremodule.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()。这个接口返回windowWidthwindowHeight,单位是逻辑像素。但 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 棋盘居中与边距的选择

我一般不在全屏铺满棋盘,而是留出上下两个区域:上面显示当前回合和比分,下面留一点手指操作缓冲。棋盘绘制从marginXmarginY开始,把网格区域控制在屏幕中央偏下。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,那么触摸事件的clientXclientY对应的格子索引为:

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 落子时还要做一次边界校验与已有棋子判断

拿到gridXgridY后,不能直接落子。先判断索引是否越界,再判断该位置是否已有棋子,最后才更新数据模型:

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; }

这段代码逻辑分三层。第一层是边界保护,防止gridXgridY超出 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 竖的网格线。背景色用#DEB887(Burlywood)模拟木质效果,比纯白有辨识度。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是全局的,当你画了一个「重新开始」按钮在棋盘上方,按钮区域也属于触摸事件的一部分。如果按钮xywidthheight与棋盘热区重叠,玩家点按钮时会同时触发落子逻辑,出现「刚点重开,棋盘上莫名其妙多了一颗子」的灵异事件。

解决办法是在触摸回调里加一层热区判断:

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.jswx.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% 的白屏症状。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询