简介:这份微信小游戏源码实现单机五子棋对战,适合刚接触微信小游戏开发的新手,也适合快速了解小游戏工程结构的学习者参考。压缩包共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.json | deviceOrientation | 声明屏幕方向 | 拼写错误导致横竖屏错乱 |
| game.json | showStatusBar | 是否显示状态栏 | 布尔值写成字符串 |
| project.config.json | compileType | 项目类型为 game | 写成了 miniprogram |
| project.config.json | appid | 项目唯一标识 | 空的或测试号 |
| 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),在真机上微秒级完成。
| 方向 | dx | dy | 判断目标 |
|---|---|---|---|
| 水平 | 0 | 1 | 从左到右的连线 |
| 垂直 | 1 | 0 | 从上到下的连线 |
| 右下斜 | 1 | 1 | 左上到右下的对角线 |
| 右上斜 | 1 | -1 | 左下到右上的对角线 |
我在实际测试中发现一个容易漏掉的错误:反向扫描时,有人会忘记把row - dx * step的边界检查也写上。如果dx为 1 且row为 0,row - 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)星位上,这也是人类玩家最习惯的开局方式。
本文还有配套的精品资源,点击获取