微信小游戏五子棋源码拆解:Canvas渲染与AI评分指南

发布时间:2026/9/14 14:47:28
微信小游戏五子棋源码拆解:Canvas渲染与AI评分指南 简介这份微信小游戏源码实现单机五子棋对战适合刚接触微信小游戏开发的新手也适合快速了解小游戏工程结构的学习者参考。压缩包共6个文件主体为3个js脚本分别涉及入口启动、主循环与棋盘逻辑处理2个json文件用于项目与游戏配置1个md说明文档提供基础使用与结构介绍整体仅4KB代码量非常精简方便逐行分析。已有1960人学习下载。通过这个小工程可以学习棋盘初始化与绘制、黑白双方轮流落子、五子连珠胜负判定等实现并理解小游戏运行时的文件组织与配置加载机制项目未引入复杂框架核心逻辑集中在少数脚本内便于打断点调试和修改验证也可在此基础上为电脑端补充简单AI扩展为人机对战练习。对于想用实际项目入门微信小游戏开发、梳理经典棋类游戏小程序化思路的初学者而言是一份轻量而完整的参考样例。1. 五子棋小游戏微信小游戏入门最该拆的源码包微信小游戏这几年迭代了不少架构很多网上流传的Demo还在用老掉牙的wx.createContext接口导入开发者工具就直接报错。而这个五子棋源码包的结构非常干净game.js、main.js、game.json各司其职没有多余的依赖库也没有云开发配置。单机对战不需要服务器导入项目后改两行参数就能看到棋盘和棋子跑起来。它覆盖了小游戏最核心的三件事入口注册、Canvas 渲染、触摸事件处理。适合两类人一类是刚啃完官方文档但不知道代码该怎么组织的新手另一类是要在团队里快速搭一个可演示 Demo 的从业者。我拆这个包时最大的感受是五子棋的规则足够简单但逻辑完整性一点不少非常适合用来建立小游戏项目的全局观。这篇就按入口骨架、核心逻辑、渲染交互、AI 扩展四条线拆开讲。2. 先看骨架从 game.js 到 main.js 的启动链路2.1 微信小游戏的入口约定微信小游戏和小程序不一样没有 WXML 和 WXSS页面上看到的所有内容都画在一张 Canvas 上。小游戏要求代码根目录下必须有game.js作为入口这是微信客户端加载项目时的固定查找文件缺少它就无法启动。game.json则是运行时的配置文件用来声明设备方向、状态栏、网络超时等参数。你还会看到project.config.json那是微信开发者工具的项目级配置记录了 appid、编译设置、工具版本等信息。换句话说game.js是运行入口game.json是运行时配置project.config.json是开发时配置三者职责完全不同新手经常把后两个搞混。微信的加载顺序是先读game.json做环境初始化再执行game.js。如果项目有分包分包入口也要在game.json里声明但五子棋没有分包需求所以暂时不用关心。入口文件game.js里的代码通常非常短一般只有一行require(./js/main.js)它的作用只是把真正的主逻辑模块引进来。把入口写短是个好习惯后续加广告、加分享回调时在入口层统一挂载会更方便。2.2 game.json 与 project.config.json 里的关键项一个能跑起来的五子棋小游戏game.json只需要最基础几项{ deviceOrientation: portrait, showStatusBar: false, networkTimeout: { request: 10000 } }deviceOrientation用来锁定屏幕方向portrait表示竖屏换成landscape就是横屏。五子棋棋盘是正方形的竖屏时上下会留白横屏时左右留白这个源码默认竖屏针对单手操作场景是合理的。showStatusBar控制是否在顶部显示系统状态栏false表示游戏内自己控制 UI这也是大多数小游戏的默认行为。networkTimeout虽然五子棋用不到网络但保留这个配置可以避免后续接入排行榜时出现请求超时问题。接着看project.config.json它里面有一个关键字段是compileType必须设为game而不是miniprogram。如果你导入项目后开发者工具提示“不是有效的小游戏项目”优先检查两点根目录有没有game.js以及project.config.json里的appid是否写成了touristappid。用游客模式可以跳过注册 appid 直接预览但真机预览时必须替换成自己的小游戏 appid。这两份配置文件的参数不多但错了任何一个都会让项目启动失败。我把它们和源文件对照整理成了表方便后续排查文件关键字段作用常见错误game.jsondeviceOrientation声明屏幕方向拼写错误导致横竖屏错乱game.jsonshowStatusBar是否显示状态栏布尔值写成字符串project.config.jsoncompileType项目类型为 game写成了 miniprogramproject.config.jsonappid项目唯一标识空的或测试号game.js-小游戏入口文件缺失导致无法导入2.3 main.js 把 Canvas 和事件循环接起来主逻辑在main.js里打开后通常是这样const { windowWidth, windowHeight, pixelRatio } wx.getSystemInfoSync(); const canvas wx.createCanvas(); const context canvas.getContext(2d); const game new Game(canvas, context); wx.onTouchStart((event) { const touch event.touches[0]; game.handleTap(touch.clientX * pixelRatio, touch.clientY * pixelRatio); });这里有几个关键点。wx.createCanvas()第一次调用时创建一个全屏 Canvas作为主屏画布被客户端上屏如果再次调用创建出来的就是离屏 Canvas用来做缓存绘制。五子棋项目只需要一个主屏 Canvas所以这个调用是安全的。触摸事件的clientX和clientY是逻辑像素坐标而 Canvas 绘制时使用的是物理像素坐标所以必须乘以pixelRatio否则所有触摸点都会偏移到左上角。这个换算错误是微信小游戏新手反馈最多的 bug没有之一。Game类是核心业务封装它接收 canvas 和 context内部维护棋盘数组、当前玩家、悔棋栈等状态。handleTap负责把触摸点换算成棋盘交叉点坐标然后执行落子和胜负判定。整套代码没有使用requestAnimationFrame因为五子棋是静态局面只有触摸事件发生时才需要重绘这样的设计最省电也符合小游戏的性能规范。后面如果有落子动画需求再在main.js里引入时间循环即可。2.4 eslintrc.js 带来的工程约束项目里还有一个.eslintrc.js这是 ESLint 的配置文件用来约束代码风格。小游戏运行本身不依赖它但它能在开发阶段拦截低级错误。比如常见的未定义变量、全局变量污染、多余分号等都会在保存时被标红。配置里通常会声明env包含node和browser环境因为小游戏运行时有wx全局对象需要globals白名单里加上wx否则wx.onTouchStart会直接报 ESLint 错误。如果你用的编辑器没有安装 ESLint 插件这个文件可以暂时忽略但建议保留后续团队协作时它能统一大家的代码风格避免出现有人用 Tab 缩进、有人用空格的尴尬局面。到这里项目的启动链路已经清了game.json配置环境game.js引入入口main.js创建画布并注册触摸事件。下一步就要看五子棋本身的核心算法。3. 五子棋核心逻辑棋盘建模、落子与五连判定3.1 棋盘建模二维数组是唯一的选择五子棋棋盘是 15×15 的交叉点矩阵最常见的表示方式是二维数组。有人喜欢用一维数组模拟砖块式布局比如index row * 15 col但这样代码可读性差而且胜负判定时需要频繁做除法和取模运算在小游戏这种场景下没有必要。二维数组直观且贴近数据结构教材上的矩阵表示调试时可以直接打印grid[7][7]看某个点是否为空。定义方式如下const BOARD_SIZE 15; const EMPTY 0; const BLACK 1; const WHITE 2; class Board { constructor() { this.grid Array.from({ length: BOARD_SIZE }, () new Array(BOARD_SIZE).fill(EMPTY)); } }这里有一个非常容易踩的坑new Array(15).fill(new Array(15).fill(0))会让 15 个行指向同一个数组引用也就是说改grid[0][0]会同步把grid[1][0]改成相同值棋盘直接废掉。使用Array.from让每一行通过箭头函数生成新的数组才能保证各行独立。BLACK和WHITE用数字常量而不是字符串一是比较操作更快二是后续做 AI 评分时可以直接把数字当成权重系数参与运算。3.2 落子前的校验坐标、空位、轮次落子不是简单地在数组里赋值需要考虑三个条件触摸点是否落在棋盘有效范围内、目标交叉点是否为空、当前是否轮到了这个玩家。坐标换算时需要把触摸的物理像素坐标减掉棋盘边距再除以格子尺寸最后四舍五入取整handleTap(x, y) { const col Math.round((x - MARGIN) / CELL_SIZE); const row Math.round((y - MARGIN) / CELL_SIZE); if (col 0 || col BOARD_SIZE) return; if (row 0 || row BOARD_SIZE) return; if (this.grid[row][col] ! EMPTY) return; this.grid[row][col] this.currentPlayer; this.moveHistory.push({ row, col, player: this.currentPlayer }); if (this.checkWin(row, col, this.currentPlayer)) { this.showResult(this.currentPlayer); return; } this.moveCount; this.switchPlayer(); }入口参数x和y一定是已经乘过pixelRatio的物理像素坐标因为main.js里处理过了。Math.round的作用是把格点吸附到最近的交叉点例如点在第 7 第 8 格中间时会偏向右下的交叉点这个误差在视觉上几乎不可感知。落子成功后把棋步记录到moveHistory后续实现悔棋时直接弹出栈顶即可。需要注意的是moveCount的递增放在checkWin之后是为了在获胜时不需要回退计数。这段代码把校验、记录、判定分得清清楚楚比把所有逻辑塞到main.js的onTouchStart里要好得多。如果你在源码里看到类似isValid()的独立方法也是同样的思路只是把边界检查拆了出去核心逻辑没有差别。3.3 胜负判定沿四个方向扫描连续同色子五子棋的胜利条件是在横、竖、左斜、右斜任一方向出现连续 5 个同色棋子。这里的关键优化是不需要每次落子后全盘扫描所有棋子只需要检查最后落下的这个点。因为新五连必然包含最后落子的位置所以以它为起点向四个方向的正反两端延伸计数即可checkWin(row, col, player) { const directions [ [0, 1], // 水平方向逐步向右探测 [1, 0], // 垂直方向逐步向下探测 [1, 1], // 右下斜线 [1, -1] // 右上斜线注意 y 方向递减 ]; for (const [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 BOARD_SIZE) break; if (nc 0 || nc BOARD_SIZE) break; if (this.grid[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 BOARD_SIZE) break; if (nc 0 || nc BOARD_SIZE) break; if (this.grid[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }方向向量表中的dx和dy代表了棋盘上四个基础的移动方向。每个方向都分正负两步走正方向累加连续棋子数反方向再累加最后判定总和是否达到 5。这里为什么要限制step 5而不是直接探测到棋盘边缘因为五连只需要 5 个棋子如果正反加起来都不足 5就没必要再往下探测提前结束循环能省掉不少无意义的边界判断。整个函数最坏情况是 4 个方向乘以 9 次数组访问复杂度小于 O(72)在真机上微秒级完成。方向dxdy判断目标水平01从左到右的连线垂直10从上到下的连线右下斜11左上到右下的对角线右上斜1-1左下到右上的对角线我在实际测试中发现一个容易漏掉的错误反向扫描时有人会忘记把row - dx * step的边界检查也写上。如果dx为 1 且row为 0row - dx * step会变成负数grid[-1][col]在 JavaScript 里不会直接报错而是访问到undefined导致比较失败结果可能误判为没有达成五连。所以边界检查必须同时覆盖正向和反向。3.4 平局判定与重新开局当棋盘填满 225 个交叉点且没有人获胜时游戏必须进入平局状态。这个判断可以在每次落子后检查moveCount是否等于BOARD_SIZE * BOARD_SIZE。平局处理与小游戏 UI 结合时可以弹出一个蒙层显示“平局”并给出重开按钮。重开逻辑要做的三件事是把grid重新初始化为全空、清空moveHistory、把当前玩家重置为黑方。我这里习惯用一个reset()方法统一处理避免在菜单回调里分散地做状态清理。到这里核心算法已经完整从棋盘初始化到落子校验再到胜负判定和平局兜底。接下来要处理的是让玩家看到的这部分——Canvas 渲染。4. 渲染与交互Canvas 绘制棋盘和棋子4.1 绘制棋盘网格、交叉点和星位Canvas 绘制棋盘的起点是定义边距和格子大小。在 15 路棋盘上通常有 15 条横线和 15 条竖线这些线交叉形成 14×14 个格子。这里的边距MARGIN是棋盘线到屏幕边缘的距离CELL_SIZE是相邻两条线的间距。绘制时先画线再画星位const MARGIN 20; const CELL_SIZE 16; function drawBoard(context) { context.lineWidth 1; context.strokeStyle #5a3e1b; context.beginPath(); for (let i 0; i BOARD_SIZE; i) { const x MARGIN i * CELL_SIZE; context.moveTo(x, MARGIN); context.lineTo(x, MARGIN (BOARD_SIZE - 1) * CELL_SIZE); context.moveTo(MARGIN, i * CELL_SIZE MARGIN); context.lineTo(MARGIN (BOARD_SIZE - 1) * CELL_SIZE, i * CELL_SIZE MARGIN); } context.stroke(); drawStarPoints(context); }这段代码里最容易写错的地方是BOARD_SIZE - 1。15 条线之间的间距数量是 14所以棋盘最后一根线的坐标是MARGIN (BOARD_SIZE - 1) * CELL_SIZE。如果直接乘BOARD_SIZE最后一条线会超出应有的棋盘范围导致最右边的棋子画到棋盘外。drawStarPoints是画星位五子棋棋盘上有五个固定星位分别位于(3, 3)、(3, 7)、(7, 7)、(11, 7)、(11, 11)绘制时用context.arc填充小圆即可。星位的作用不只是美观它还能帮助玩家快速定位棋盘中心真机上手指粗的人会下意识往星位附近落子。关于这里的布局参数我常用本机适配的方式计算先将逻辑屏宽375转成物理屏宽再取MARGIN Math.floor(physicalWidth * 0.03)CELL_SIZE Math.floor((physicalWidth - 2 * MARGIN) / (BOARD_SIZE - 1))。这样能让棋盘在窄屏和宽屏上都贴合边缘不额外适配机型。4.2 棋子绘制用径向渐变代替纯色圆纯色圆形的棋子在小游戏里会显得非常扁平缺乏质感。使用createRadialGradient能模拟棋子的高光和暗部让黑白子看起来更立体function drawPiece(context, row, col, player) { const x MARGIN col * CELL_SIZE; const y MARGIN row * CELL_SIZE; const radius CELL_SIZE * 0.42; const gradient context.createRadialGradient( x - 2, y - 2, radius * 0.2, x, y, radius ); if (player BLACK) { gradient.addColorStop(0, #6a6a6a); gradient.addColorStop(1, #1a1a1a); } else { gradient.addColorStop(0, #ffffff); gradient.addColorStop(1, #e0e0e0); } context.beginPath(); context.arc(x, y, radius, 0, Math.PI * 2); context.fillStyle gradient; context.fill(); context.lineWidth 0.5; context.strokeStyle #d0d0d0; context.stroke(); }渐变中心的偏移量x - 2和y - 2不是随意的它表示高光点位于棋子的左上方位模拟头顶光从左前方照下来的效果。半径取CELL_SIZE * 0.42而不是 0.5是为了让相邻棋子之间有 0.16 倍格子大小的间隙这样两个棋子叠在一起时依然能看出边缘轮廓不会糊成一片。黑色棋子使用深灰到黑的渐变白色棋子使用纯白到浅灰的渐变每一颗棋子绘制完都用半透明的浅色描个边在白色棋盘上能增加边界感。这里还有一个性能细节绘制棋子前不需要清掉这一格原来的棋盘线因为棋子半径小于格子间距的一半棋子会自然覆盖住交叉点上的线。但如果你的格子尺寸特别小比如CELL_SIZE 12棋子半径接近 5此时棋盘线的宽度可能透出来可以在落子后重绘一次整条交叉线或者将棋子半径再缩小 15%。4.3 触摸坐标换算逻辑像素和物理像素中间的桥微信小游戏里所有触摸事件的坐标都是逻辑像素而 Canvas 默认的绘制坐标系是物理像素。主屏 Canvas 的宽高等于windowWidth * pixelRatio如果你直接用clientX作为绘制坐标真机会因为 devicePixelRatio 大于 1 而出现所有触摸点偏到左上角的经典 bug。解决方式有两种第一种是手动换算在main.js中用一个函数包装function toBoardCoords(touchX, touchY) { const px touchX * pixelRatio; const py touchY * pixelRatio; const col Math.round((px - MARGIN * pixelRatio) / (CELL_SIZE * pixelRatio)); const row Math.round((py - MARGIN * pixelRatio) / (CELL_SIZE * pixelRatio)); return { row, col }; }第二种更推荐创建上下文后直接做一次缩放context.scale(pixelRatio, pixelRatio); canvas.width windowWidth; canvas.height windowHeight;这样后续所有绘制和触摸坐标都使用逻辑像素代码更简洁。但要注意这种写法下Canvas.width会被改成逻辑宽度真机上会感觉画面变模糊。所以正确做法是保留canvas.width windowWidth * pixelRatio同时调用context.scale(pixelRatio, pixelRatio)让 Canvas 物理分辨率足够高又让绘图坐标保持在逻辑空间。这个技巧在源码里往往不会写明但你在main.js里看到的canvas.width赋值和context.scale调用组合在一起时就应该意识到这是为高清屏做的适配。Canvas 方法在本项目中的作用createRadialGradient绘制棋子时生成渐变背景arc绘制棋子圆形路径scale把坐标系从逻辑像素映射到物理像素clearRect重绘前清空画布避免残影4.4 重绘策略全量重绘与脏矩形取舍五子棋的棋盘是 15×15全量重绘一次大约需要绘制两百多根线和棋子在大多数安卓真机上耗时三到四毫秒。这个耗时完全可以接受所以我在这个项目里采用最直观的全量重绘function render() { context.clearRect(0, 0, canvas.width, canvas.height); drawBoard(context); for (let row 0; row BOARD_SIZE; row) { for (let col 0; col BOARD_SIZE; col) { if (grid[row][col] ! EMPTY) { drawPiece(context, row, col, grid[row][col]); } } } }clearRect接收的宽高要和 Canvas 的物理尺寸一致如果只传windowWidth而忘了乘以pixelRatio画布边缘会残留上一帧的内容。如果以后要做落子动画可以引入脏矩形记录这次触摸变更的格子位置只重绘这个格子的背景和棋子。不过在五子棋项目里全量重绘的代码更简单出问题也更容易排查。微信官方推荐在主屏 Canvas 上偶尔使用canvas.requestAnimationFrame做动画循环但静态游戏完全可以让渲染函数只被触摸事件触发这样省资源也更贴近微信对小游戏耗电的要求。渲染和交互一旦跑通整个双人对战版本就完成了。如果想让单机玩家有挑战性接下来加一个最基础的 AI 对手。5. 加个人机对手棋型评分与落子特判5.1 给每个空位打分的思路人机五子棋最直接的做法是先遍历棋盘上所有空白交叉点给每个点打分然后选最高分落子。打分依据是这个点对双方棋型的影响力。棋型本身可以通过方向扫描来判断以当前空位为中心沿四个方向统计连续同色棋子的数量以及两端是否被堵住把结果映射成一个分数。这里不需要构建复杂的博弈树小游戏运行环境性能有限深度搜索反而会在真机上卡顿。评分策略足够应付大多数休闲玩家。function evaluatePoint(board, row, col, aiPlayer) { const human aiPlayer BLACK ? WHITE : BLACK; let score 0; score evaluateDirection(board, row, col, aiPlayer); score evaluateDirection(board, row, col, human) * 1.2; return score; }evaluateDirection会返回该点在某方向上的棋型分值。human的方向分值乘上 1.2 的放大系数表示“防守优先”当 AI 和玩家在同一个位置都能形成威胁时AI 会优先堵玩家。这个系数是可调的调成 0.8 会让 AI 偏向进攻调成 1.5 会让 AI 变得保守读者可以按自己的手感和测试结果改。5.2 棋型分值的定义与方向扫描为了让 AI 有基本判断力需要先定义棋型的分数表。这部分代码通常是纯函数便于单测const SCORES { FIVE: 100000, OPEN_FOUR: 10000, LIVE_THREE: 5000, SLEEP_FOUR: 4000, LIVE_TWO: 500, SLEEP_THREE: 200, SLEEP_TWO: 50 };分值只做相对排序不要求绝对精确。FIVE表示直接获胜局型必须最高OPEN_FOUR是两端都开放的活四这种棋型无论对方怎么堵都能连成五得分次高LIVE_THREE是还能变成活四的三需要优先堵SLEEP_FOUR是被堵住一端的冲四同样很危险。PS如果测试发现 AI 总是无视对方连成的三子检查一下LIVE_THREE的防守权重是否被调低了。方向扫描的代码和胜负判定很像区别在于它还要统计两端的状态。以水平方向为例从当前点向左数连续同色子数量leftCount再检查再左边一格是否为空向右同理。如果左端不仅是空位而且再往左一个位置也是空位那就属于“开放”的棋型如果某一端是对方棋子或者棋盘边界就算“被堵”。得到左右两端的开放状态后在SCORES表里查分段计分。为了控制篇幅这里不列出完整的evaluateDirection实现核心就是在checkWin的方向循环里额外维护blockedCount和openCount两个变量。5.3 落子前的两个特判正式搜索之前先做两个 O(225) 的检查能明显提升 AI 的应对质量先找 AI 有没有一步成五的点有就直接下这是“一击必杀”再找玩家有没有一步成五的点有就立刻堵这是“防守保命”。这两个特判执行在评分扫描之前不会额外增加很多计算量但能避免 AI 在一手可胜的局面下还去走一个“活三”棋型。如果这两个点不存在再进入evaluatePoint全盘评分。接入现有源码时我建议在Game类里加一个mode字段区分双人和人机模式。AI 的落子通过setTimeout(() this.aiMove(), 200)延迟 200 毫秒执行这样有一个自然的思考间隙玩家不会觉得是游戏卡了。aiMove内部计算出目标格子的row和col后直接调用this.handleTap对应的坐标换算逻辑注意要先把格子坐标转回物理像素坐标或者干脆复用grid[row][col]的落子函数。这个小技巧能保证 AI 的落子会经过和人类玩家完全相同的校验流程不会出现 AI 无视棋盘状态的问题。如果你在真机上测试发现 AI 第一手不会下在中枢可以在aiMove里加一个判断如果历史步数为空直接落在(7, 7)星位上这也是人类玩家最习惯的开局方式。本文还有配套的精品资源点击获取