微信小程序Canvas 2D实战:双人五子棋从棋盘绘制到对局管理
2026/9/15 2:43:26 网站建设 项目流程

简介:微信小程序项目实例——双人五子棋是一份面向小程序开发初学者的完整项目源码,适合学习前端三件套在微信环境中的实际应用,能帮助理解 WXML 布局、WXSS 样式与 JavaScript 逻辑的协作方式,以及基于 Canvas 绘制棋盘、实现点击落子、五子连珠胜负判定和悔棋等游戏功能。压缩包共10个文件,以 json、js、wxss、wxml 四种类型为主:js 承担对弈规则与状态维护,json 处理项目配置,wxss/wxml 搭建界面框架,整包仅6KB,结构清晰,便于快速阅读和二次修改。该资源已有1895人学习下载,通过拆解源码可以掌握双人交替下棋时玩家状态管理、棋盘数据记录、历史步骤回溯等常见实现技巧,还可学习适应不同屏幕尺寸的布局优化与小程序性能优化方法。整体来看,这是一款将前端技术、游戏算法和交互体验相结合的轻量级练手项目,对想提升小程序开发能力的读者很有参考价值。

1. 双人五子棋小程序,先想清楚棋盘和落子怎么对齐

微信小程序游戏开发里,双人五子棋常被当成入门练手项目:规则只有一条“五子连线”,比俄罗斯方块、飞机大战的物理逻辑简单得多。但真正把项目从“能跑”做到“能玩”,问题全在棋盘之外:15 路棋盘用什么画、触控点怎么换算成格子、胜负判定怎么覆盖横竖斜四个方向、悔棋之后对局状态怎么回退。下面按这个顺序走一个完整实例,一个 Page 方法就能跑通核心对局。适合正在做微信小程序项目实例的开发者,也适合拿它当小程序课设、毕业设计底子的人。

2. 棋盘渲染选型与页面结构搭建

微信小程序里画棋盘有两条路:用 view 铺 15 x 15 的格子,或者用 canvas。第一次做的人倾向用 view,因为 WXML 里嵌套循环就能生成棋盘,点击事件自带><view class="game-page"> <view class="header"> <view class="player {{currentPlayer === 1 ? 'active' : ''}}">黑棋</view> <view class="move-info">第 {{moveCount}} 手</view> <view class="player {{currentPlayer === 2 ? 'active' : ''}}">白棋</view> </view> <canvas type="2d" id="gomoku" class="game-canvas" bindtouchstart="onTouchStart"></canvas> <view class="footer"> <button class="btn" bindtap="onUndo">悔棋</button> <button class="btn" bindtap="onRestart">重新开始</button> </view> </view>

样式上需要处理一个容易忽略的问题:微信小程序顶部导航栏高度。如果 app.json 里 navigationStyle 用默认值,顶部已有原生导航栏,canvas 不与它冲突。如果为了沉浸式效果改成 custom,状态栏和胶囊按钮会占掉顶部空间,canvas 的 top 必须低过胶囊按钮下沿,否则手指落在按钮区域时触摸会被系统手势拦截。

我一般会声明 navigationStyle: "custom",再用 wx.getWindowInfo() 动态取状态栏高度来排布页面:

{ "pages": ["pages/game/game"], "window": { "navigationStyle": "custom", "backgroundColor": "#3a3a3a" } }

初始化画布时,把状态栏高度和底部按钮高度一起算进可用空间,保证画布是正方形。基础库较新版本推荐 wx.getWindowInfo(),旧项目里可以用 wx.getSystemInfoSync() 做兼容,两者的字段结构一致。

initCanvas() { const query = this.createSelectorQuery(); query.select('#gomoku').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const win = wx.getWindowInfo(); const dpr = win.pixelRatio; const cssWidth = win.windowWidth; const cssHeight = win.windowHeight - win.statusBarHeight - 120; const side = Math.min(cssWidth, cssHeight); canvas.width = side * dpr; canvas.height = side * dpr; ctx.scale(dpr, dpr); this.canvas = canvas; this.ctx = ctx; this.boardCssSize = side; this.cellSize = side / (BOARD_SIZE + 1); this.initBoard(); }); }

这里有两处关键设置。第一,canvas.width 和 canvas.height 设的是物理像素,设完图形上下文里坐标范围随之变大,必须紧跟 ctx.scale(dpr, dpr),后面所有绘制坐标才能真正按 CSS 像素写。第二,cellSize 用 side / (BOARD_SIZE + 1) 而不是 side / BOARD_SIZE,目的是在棋盘四边留出一个格子宽度,棋子半径约 cellSize * 0.42,不会顶到画布边缘。触摸换算和绘制共用同一个 cellSize,等于只维护一套格子尺寸。

2.2.1 棋盘参数与绘制方法

棋盘参数集中在 Page 顶部定义,方便整体调整。标准五子棋是 15 路,如果想做教学演示版,可以把 BOARD_SIZE 改成 9,同时替换星位坐标数组。

参数取值说明
BOARD_SIZE1515 x 15 棋盘,标准五子棋规格
EMPTY / BLACK / WHITE0 / 1 / 2board 二维数组里的状态值
cellSizeside / 16格子边长,同时也是棋盘边距
棋子半径cellSize * 0.42半径略小于格子一半,保证相邻棋子不粘连
dprwin.pixelRatio物理像素和 CSS 像素的比例

首屏绘制还涉及“修改刚进入的加载页面”的问题。canvas 的 node 在 onReady 之后才能查到,如果提前到 onLoad 里执行 createSelectorQuery,会查不到节点,表现就是进页面白一下,之后棋盘才出现。正确做法是在 onReady 里初始化,并且 initBoard 内部先把背景色填充完整,视觉上棋盘是瞬间出现的:

initBoard() { this.board = Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(EMPTY)); this.history = []; this.setData({ currentPlayer: BLACK, gameOver: false, moveCount: 0, winnerText: '' }); this.drawBoard(); } drawBoard() { const ctx = this.ctx; const margin = this.cellSize; ctx.fillStyle = '#f0d9a7'; ctx.fillRect(0, 0, this.boardCssSize, this.boardCssSize); ctx.strokeStyle = '#4a3f2f'; ctx.lineWidth = 1; for (let i = 0; i < BOARD_SIZE; i++) { const start = margin + i * this.cellSize; ctx.beginPath(); ctx.moveTo(margin, start); ctx.lineTo(margin + (BOARD_SIZE - 1) * this.cellSize, start); ctx.stroke(); ctx.beginPath(); ctx.moveTo(start, margin); ctx.lineTo(start, margin + (BOARD_SIZE - 1) * this.cellSize); ctx.stroke(); } const stars = [[3, 3], [3, 11], [7, 7], [11, 3], [11, 11]]; for (const [sr, sc] of stars) { ctx.beginPath(); ctx.arc(margin + sc * this.cellSize, margin + sr * this.cellSize, 3, 0, Math.PI * 2); ctx.fillStyle = '#4a3f2f'; ctx.fill(); } }

iconBoard 里先 reset board 和 history,再 setData 界面状态,最后整盘重绘。drawBoard 的横竖线循环是按“从第 0 格到第 14 格”的交叉点坐标铺线,起点和终点都落在 margin 到 margin + 14 * cellSize 之间,这样画出来的交叉点数量正好是 15 x 15。

3. 落子交互与胜负判定核心逻辑

棋盘画出来之后,实例的核心逻辑集中在两个函数:落子换算和胜负判定。坐标换算错了,棋子会落在交叉点之外;判定方向漏一个,斜线上的五连就判不出来。这两块分开写,后续要加禁手规则或者改 AI 都会容易很多。

3.1 触控坐标到棋盘格子的换算

Canvas 2D 的 touch 事件里,e.touches[0].x 和 e.touches[0].y 是相对 canvas 节点的本地坐标,不需要再减 offsetLeft。棋盘从 (cellSize, cellSize) 开始画线,第 row 行第 col 列的交叉点坐标是 (cellSize + col * cellSize, cellSize + row * cellSize)。所谓落子换算,就是拿触摸点坐标求离它最近的交叉点。

onTouchStart(e) { if (this.data.gameOver) return; const touch = e.touches[0]; const col = Math.round((touch.x - this.cellSize) / this.cellSize); const row = Math.round((touch.y - this.cellSize) / this.cellSize); if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; if (this.board[row][col] !== EMPTY) return; this.setPiece(row, col); } setPiece(row, col) { const player = this.data.currentPlayer; this.board[row][col] = player; this.history.push({ row, col, player }); this.drawPiece(this.ctx, row, col, player); if (this.checkWin(row, col, player)) { this.setData({ gameOver: true, winnerText: player === BLACK ? '黑方胜' : '白方胜' }); wx.showToast({ title: this.data.winnerText, icon: 'none' }); return; } this.setData({ currentPlayer: player === BLACK ? WHITE : BLACK, moveCount: this.history.length }); }

用 Math.round 而不是 Math.floor 的原因在于棋盘边距本身也是 cellSize。触摸点落在两个交叉点中间时,Math.round 会把落点分给更近的一侧,手感更自然;如果落在画布边角,离所有交叉点都超过半格,row 或 col 会越界,直接 return 即可。这个换算不需要额外调精度,因为 cellSize 来自实际画布宽度,模拟器和真机的比例保持一致。

drawPiece 是局部绘制,只画当前这一子,不做整盘刷新:

drawPiece(ctx, row, col, player) { const x = this.cellSize + col * this.cellSize; const y = this.cellSize + row * this.cellSize; const r = this.cellSize * 0.42; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = player === BLACK ? '#1a1a1a' : '#fafafa'; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.arc(x - r * 0.3, y - r * 0.3, r * 0.25, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fill(); }

棋子阴影如果直接依赖 canvas 的 shadowBlur,真机上会明显掉帧。上面代码改用两层绘制模拟高光:先画圆形主体,再在左上角叠一个半透明小圆,视觉接近阴影效果,绘制开销却小很多。

3.2 四方向五连判定

落子之后只需要检查当前这一颗棋子是否成五连,不需要每步扫描整张棋盘。方向有四种:横、竖、正斜、反斜。每个方向从当前落子点出发,沿正向数同色棋子,再沿反向数同色棋子,加上落子点自身,数量大于等于 5 就赢。

checkWin(row, col, player) { const directions = [ { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 1, dy: 1 }, { dx: 1, dy: -1 } ]; for (const item of directions) { let count = 1; count += this.countDirection(row, col, item.dx, item.dy, player); count += this.countDirection(row, col, -item.dx, -item.dy, player); if (count >= 5) return true; } return false; } countDirection(row, col, dx, dy, player) { let count = 0; let r = row + dx; let c = col + dy; while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && this.board[r][c] === player) { count++; r += dx; c += dy; } return count; }

countDirection 里 while 的三个条件是标准写法:边界判断在前,board 取值判断在后。如果把边界判断放到后面,数组越界会读到 undefined,undefined === player 为 false,结果一样,但可读性差一些。count 的初始值是 1,代表当前落下的这一子已经占了一个位置,再加两边数量的总和,就是这条线上的连子数。

方向数组单独维护成常量,后续扩展规则会更容易。这里的方向映射如下:

方向 (dx, dy)连线形状覆盖范围
(1, 0)横线左右延伸
(0, 1)竖线上下延伸
(1, 1)正斜线左上到右下
(1, -1)反斜线右上到左下
3.2.1 先手规则与交互状态

双人五子棋默认黑棋先手,currentPlayer 初始为 BLACK。setPiece 在每次落子后切换一次,悔棋时再切回上一步。因为 currentPlayer 存在 data 里,WXSS 可以直接用绑定类名给执子方加高亮边框,不需要再画额外的指示器。

gameOver 的判定要放在 drawPiece 完成之后、setData 切换 currentPlayer 之前,否则胜负已分还能继续落子。还有一个顺序细节:setData 是异步的,drawPiece 直接操作 canvas 节点不受 setData 影响,所以先绘图再 setData 不会出现棋子和界面状态闪烁的问题。真机上如果发现黑棋落子后白棋状态位先高亮,问题通常出在 currentPlayer 切换时用了旧值,而不是 setData 的时序。

4. 对局状态管理与悔棋、重开、存档

双人五子棋容易做成“一次性页面”,原因在于很多人把 board 二维数组放在 data 里,每个格子变更都走 setData,悔棋时又要逐格把颜色改回 EMPTY,代码量翻倍,性能还差。正确的做法是 board 和 history 都不进 data,只把和界面直接相关的手数、执子方放进 data。

4.1 历史栈设计与悔棋回退

history 数组记录每一步的 { row, col, player }。悔棋本质就是弹栈:取出最后一步,清空棋盘位置,重绘该格,再回到上一步的执子方。

onUndo() { if (this.history.length === 0) return; if (this.data.gameOver) { this.setData({ gameOver: false, winnerText: '' }); } const last = this.history.pop(); this.board[last.row][last.col] = EMPTY; this.drawEmpty(this.ctx, last.row, last.col); this.setData({ currentPlayer: last.player, moveCount: this.history.length }); } drawEmpty(ctx, row, col) { const x = this.cellSize + col * this.cellSize; const y = this.cellSize + row * this.cellSize; ctx.clearRect(x - this.cellSize / 2, y - this.cellSize / 2, this.cellSize, this.cellSize); ctx.fillStyle = '#f0d9a7'; ctx.fillRect(x - this.cellSize / 2, y - this.cellSize / 2, this.cellSize, this.cellSize); }

drawEmpty 和 drawPiece 是对称的局部刷新方法:drawEmpty 清除一个 cellSize 见方的区域并恢复木色底,drawPiece 画新棋子,两者都不触发整盘重绘,这是悔棋手感不卡的关键。为什么要单独处理 gameOver?因为胜负出现后,棋盘不允许继续落子,悔棋需要把对局重新打开,同时清掉 winnerText,否则界面还停在“黑方胜”,下一步落子却没有反馈。

回退时 currentPlayer 直接取 last.player,而不是靠手数奇偶推算。这样将来改成白棋先手或随机先手时,回退逻辑不用动。

重开对局则直接调 initBoard,它内部会重建 board、清空 history、重绘整盘并重置 data。

4.2 对局信息持久化与恢复

微信小程序对页面销毁后的内存回收策略比较激进,用户下棋下到一半切后台,再回来时页面可能已经被回收,对局丢失。常见做法是把对局历史写入本地文件,wx.env.USER_DATA_PATH 是小程序用户目录下唯一可写路径,不受用户手动清理缓存的影响,适合保存轻量棋谱。

saveGame() { const filePath = `${wx.env.USER_DATA_PATH}/gomoku_history.json`; const fs = wx.getFileSystemManager(); const data = JSON.stringify({ steps: this.history }); try { fs.writeFileSync(filePath, data, 'utf8'); wx.showToast({ title: '对局已保存', icon: 'success' }); } catch (e) { console.error('save history failed', e); } } restoreGame() { const filePath = `${wx.env.USER_DATA_PATH}/gomoku_history.json`; const fs = wx.getFileSystemManager(); try { const content = fs.readFileSync(filePath, 'utf8'); const obj = JSON.parse(content); if (!obj.steps || obj.steps.length === 0) return; this.initBoard(); for (const step of obj.steps) { this.board[step.row][step.col] = step.player; } this.history = obj.steps; this.drawBoard(); this.setData({ currentPlayer: this.history.length % 2 === 0 ? BLACK : WHITE, moveCount: this.history.length }); } catch (e) { wx.showToast({ title: '没有可恢复的对局', icon: 'none' }); } }

saveGame 在 onHide 里调用一次,用户切后台的瞬间对局就已经落盘,恢复时拿到的棋谱必然是完整的。writeFileSync 的第一个参数必须是 wx.env.USER_DATA_PATH 拼接相对路径,不能写成绝对路径,这也是文件系统模块的硬性限制。

4.2.1 存档字段设计

微信小程序官方文档里这个环境变量写作 wx.env.USER_DATA_PATH,早期版本也有资料写成 user_data_path,实际以运行时的大写为准。存档结构我封装成{ steps: [...] }而不是直接存数组,后续要加“对局时间”“棋谱名称”等字段时,不必改读取入口。

data 字段类型说明
currentPlayerNumber 1/2当前执子方,黑 1 白 2
gameOverBoolean对局是否已决出胜者
moveCountNumber已落子数,等于 history.length
winnerTextString胜方提示文案

恢复对局时先调 initBoard 重建 board 和重绘空棋盘,再按 steps 把棋子写回去,最后单独调一次 drawBoard 画完整局面。这样磁盘文件和内存状态都以 history 为唯一依据,不会出现棋盘上某颗棋子和历史记录对不上的问题。恢复后的 currentPlayer 用步数奇偶推断:黑棋先手,偶数步轮到黑,奇数步轮到白。把“悔棋后再存档”和“恢复后继续悔棋”两条链路跑通,整个对局状态管理就闭环了。

如果你用的是 uni-app 开发微信小程序,canvas 部分不能直接搬这套 SelectorQuery 代码。uni-app 里 canvas 要走 uni.createCanvasContext 这类历史接口,渲染时机和坐标系也不同。本实例按微信小程序原生语法编写,迁移到 uni-app 时优先整体替换绘图层,不要只改初始化那几行。另外,线上双人模式需要 WebSocket 和房间系统,这套落子、判定、悔棋逻辑可以复用,但状态同步和掉线恢复要另做设计。

5. 真机坐标偏差排查与批量落子自测

模拟器里的触摸事件和真机的触摸事件来源不同,微信小程序开发者工具里 Canvas 2D 的 touch.x 几乎零偏差,真机上 iOS 和 Android 的 WebView 对 canvas 本地坐标的计算则受样式影响。遇到落子偏移半个格子的问题,按下面三步排查。

第一,检查 canvas 外层 view 有没有 transform 或动画。任何非 none 的 transform 都会改变触摸事件的坐标系,导致 touch.x 与绘制坐标对不上,双人五子棋这类全屏棋盘完全不需要 transform,看到就直接去掉。第二,检查页面是否可滚动。如果 canvas 在滚动容器里,touch 坐标会跟随滚动偏移量变化,正确做法是把棋盘固定为不可滚动区域,或者手动扣除滚动距离。第三,确认 ctx.scale(dpr, dpr) 之后没有再调用 setTransform,重复设置会把坐标换算打乱,棋子就会画到交叉点外。

5.1 局部绘制自测脚本

验证胜负判定最直接的方式,是在 Page 里写一个临时方法,用随机数批量落子,观察控制台是否正确输出五连结果。这个方法比手点几百下高效得多,适合在接入悔棋和存档功能后做回归测试。

runSelfTest() { this.initBoard(); const players = [BLACK, WHITE]; let guard = 0; while (guard < 200) { const row = Math.floor(Math.random() * BOARD_SIZE); const col = Math.floor(Math.random() * BOARD_SIZE); if (this.board[row][col] !== EMPTY) { guard++; continue; } const player = players[this.history.length % 2]; this.board[row][col] = player; this.history.push({ row, col, player }); if (this.checkWin(row, col, player)) { console.log('detect win at', row, col, 'player', player, 'steps', this.history.length); this.drawBoard(); return; } guard++; } console.log('no win in self test'); }

runSelfTest 不调用 setData,只操作内部 board 和 history,不会频繁触发视图更新,跑起来很快。guard 计数器防止随机落子阶段频繁碰壁导致无限循环,实际上一局最多 200 手必然会结束。在微信小程序开发者工具的 Console 里执行 this.runSelfTest(),如果循环到 200 手都没有任何输出,就要回头检查 checkWin 里四个方向的 dx、dy 是否写反。这个脚本还适合验证 9 路棋盘参数:把 BOARD_SIZE 改小后跑一次自测,胜局出现时打印的坐标如果落在边界上,说明边界判断失效。

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

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

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

立即咨询