简介:本资源是一套基于微信小程序实现局域网联机对战的五子棋游戏完整源码,面向小程序开发者及前端学习者,解决单机游戏向多人实时交互场景延伸的技术实践难题。压缩包共38个文件,含11个JS逻辑文件(如lan.js、util.js实现WiFi直连与状态同步)、8个WXSS样式文件、7个WXML页面结构文件及11个JSON配置文件(含app.json、sitemap.json等),整体仅24KB,轻量易读,便于快速理解小程序多端通信与游戏状态协同机制。已有1250人学习下载,适合作为微信小游戏进阶开发的参考范例。读者可直接在微信开发者工具中编译运行,完整复现扫描配对、双端同步落子、胜负判定等核心流程;代码结构清晰,pages目录下game_vs、game_scan、game_gobang模块分工明确,配套readme.md与两篇深度技术博文(单机版与联机改造)形成闭环学习路径。
1. 微信小程序五子棋联机游戏:不是“单机改个 wx.request 就能上线”的事
你拿到一份标着“五子棋-联机游戏-微信小程序源码”的压缩包,解压后看到pages/game/下一堆.wxml、.js和utils/里的socket.js,第一反应可能是:“这不就是把本地落子逻辑加个 WebSocket 发给服务器?”——但实际部署时,90% 的人卡在第二步:服务端根本没跑起来,或者连上就断、落子不同步、多人对局状态错乱。这不是前端代码写得不够漂亮的问题,而是联机五子棋在小程序生态里天然面临三重约束:微信原生 WebSocket 的连接生命周期管理、小程序冷启动导致的 socket 断连重连策略缺失、以及五子棋这类状态敏感型游戏对“操作原子性”和“时序一致性”的硬性要求。它适合两类人:一是正在做毕业设计或接外包的小程序开发者,需要可交付、可演示、可解释的联机逻辑;二是想深入理解小程序实时通信边界的技术人——本文不讲“怎么画棋盘”,只聚焦“怎么让两个手机上的玩家,真正看到同一盘棋的每一步”。所有代码均基于微信基础库 2.28.2+,适配真机调试与体验版发布。
2. 联机核心:用 WebSocket 实现确定性棋局同步,而非轮询或云开发数据库监听
2.1 为什么必须用 WebSocket?轮询和云数据库监听为何不可靠
五子棋是强状态一致性游戏:A 落子后,B 必须在 200ms 内看到该子生效,且不能出现“A 看到自己赢了,B 还显示平局”的情况。若采用 HTTP 轮询(如每 500mswx.request查询最新棋谱),会引入至少 1 秒延迟,且无法保证两玩家查询到的“最新状态”来自同一时间戳;若依赖云开发watch监听集合变更,虽实时性提升,但存在事件丢失风险——当玩家从后台切回前台时,watch可能错过中间若干次update事件,导致棋盘状态永久错位。WebSocket 提供全双工、低延迟、有序帧传输,是唯一能保障“操作即刻广播、接收即刻渲染”的通道。微信小程序中,wx.connectSocket是唯一原生支持的长连接方案,其底层复用微信客户端网络栈,比自建 HTTP 长轮询更省电、更稳定。
提示:不要尝试用
wx.request模拟长连接。微信对单个域名的并发请求数有限制(通常为 10),且每次请求都有 TCP 握手开销,高频落子下极易触发request:fail timeout或429 Too Many Requests。
2.2 服务端选型:Node.js + Socket.IO(兼容小程序 WebSocket)最小可行架构
小程序wx.connectSocket仅支持标准 WebSocket 协议(RFC 6455),不兼容 Socket.IO 的自定义握手协议。因此服务端必须提供原生 WebSocket 接口,而非直接使用socket.io。常见做法是选用ws库(轻量、无依赖、性能高)构建服务端:
// server.js const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // 存储房间与玩家映射:{ roomId: { playerA: ws, playerB: ws, board: [...], turn: 'A' } } const rooms = new Map(); wss.on('connection', (ws, req) => { const url = new URL(req.url, 'http://localhost'); const roomId = url.searchParams.get('room'); const playerId = url.searchParams.get('player'); // 'A' or 'B' if (!roomId || !playerId) { ws.close(4001, 'Missing room or player'); return; } if (!rooms.has(roomId)) { rooms.set(roomId, { players: {}, board: Array(15).fill().map(() => Array(15).fill(0)), turn: 'A' }); } const room = rooms.get(roomId); room.players[playerId] = ws; // 广播房间已满 if (Object.keys(room.players).length === 2) { Object.values(room.players).forEach(client => { client.send(JSON.stringify({ type: 'ready', roomId })); }); } ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); if (msg.type === 'move' && room.turn === playerId) { const { x, y } = msg; if (room.board[x][y] === 0) { room.board[x][y] = playerId === 'A' ? 1 : 2; room.turn = playerId === 'A' ? 'B' : 'A'; // 广播给双方 Object.values(room.players).forEach(client => { client.send(JSON.stringify({ type: 'update', board: room.board, turn: room.turn, lastMove: { x, y, player: playerId } })); }); } } } catch (e) { console.error('Invalid message:', e); ws.send(JSON.stringify({ type: 'error', msg: 'Invalid move format' })); } }); ws.on('close', () => { delete room.players[playerId]; if (Object.keys(room.players).length === 0) { rooms.delete(roomId); } }); }); console.log('WebSocket server running on ws://localhost:8080');这段代码实现了最简联机逻辑:
- URL 参数传递身份:小程序连接时传
?room=abc123&player=A,避免登录态校验复杂度; - 内存存储棋局状态:
room.board是二维数组,值0/1/2分别代表空、黑子、白子,符合五子棋规则; - 严格回合制校验:
if (room.turn === playerId)确保只有当前玩家能落子,防止前端伪造请求; - 广播而非单发:
Object.values(room.players).forEach(...)保证双方收到完全一致的状态更新,消除“谁先看到”的竞态。
2.3 小程序端 WebSocket 连接管理:处理冷启动、断连重试与心跳保活
小程序进入后台后,系统可能回收 WebSocket 连接(iOS 尤甚),前台恢复时需自动重连。单纯wx.onAppShow监听不够,必须结合onClose事件与指数退避重试:
// utils/socket.js class GameSocket { constructor(roomId, playerId) { this.roomId = roomId; this.playerId = playerId; this.ws = null; this.reconnectTimer = null; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; // 初始延迟 1s } connect() { const url = `wss://your-domain.com?room=${this.roomId}&player=${this.playerId}`; this.ws = wx.connectSocket({ url }); this.ws.onOpen(() => { console.log('WebSocket connected'); this.reconnectDelay = 1000; // 重置延迟 this.startHeartbeat(); }); this.ws.onMessage((res) => { const data = JSON.parse(res.data); this.handleMessage(data); }); this.ws.onError((err) => { console.error('WebSocket error:', err); this.scheduleReconnect(); }); this.ws.onClose(() => { console.log('WebSocket closed'); this.scheduleReconnect(); }); } startHeartbeat() { clearInterval(this.heartbeat); this.heartbeat = setInterval(() => { if (this.ws && this.ws.readyState === wx.WebSocket.READY_STATE.OPEN) { this.ws.send({ type: 'ping' }); // 服务端需响应 pong } }, 30000); } scheduleReconnect() { if (this.reconnectTimer || this.reconnectAttempts >= this.maxReconnectAttempts) return; this.reconnectTimer = setTimeout(() => { console.log(`Reconnecting... attempt ${++this.reconnectAttempts}`); this.connect(); this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // 最大 30s }, this.reconnectDelay); } send(msg) { if (this.ws && this.ws.readyState === wx.WebSocket.READY_STATE.OPEN) { this.ws.send({ data: JSON.stringify(msg) }); } } handleMessage(data) { switch (data.type) { case 'ready': this.onReady?.(); break; case 'update': this.onUpdate?.(data); break; case 'error': wx.showToast({ title: data.msg, icon: 'none' }); break; } } } module.exports = GameSocket;关键点说明:
onClose触发重连:不仅是网络断开,小程序切后台再切回前台时onClose也会被触发,这是微信的正常行为;- 指数退避:首次重连延 1s,失败后延 2s、4s、8s…避免雪崩式重连请求;
- 心跳保活:每 30s 发送
ping,服务端需响应pong(ws库默认开启 ping/pong,无需额外代码); send安全封装:检查readyState,避免WebSocket is not open错误。
3. 棋局逻辑落地:前端渲染、防误触与胜负判定的微信小程序实现
3.1 Canvas 渲染棋盘:规避 WXML 布局性能瓶颈与触摸坐标计算误差
五子棋棋盘为 15×15 网格,若用 WXML 循环生成 225 个<view>,在低端安卓机上滚动或落子时易掉帧。更优解是使用<canvas>绘制静态棋盘与动态棋子,由 JavaScript 控制像素级渲染:
<!-- pages/game/game.wxml --> <canvas canvas-id="chessCanvas" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd" style="width:100vw; height:100vh;"></canvas>// pages/game/game.js Page({ data: { board: Array(15).fill().map(() => Array(15).fill(0)), // 0=空, 1=黑, 2=白 currentPlayer: 'A', gameStatus: 'playing' // 'playing' | 'win' | 'draw' }, onLoad() { this.ctx = wx.createCanvasContext('chessCanvas', this); this.socket = new GameSocket(this.options.roomId, this.options.playerId); this.socket.onUpdate = this.onBoardUpdate.bind(this); this.socket.connect(); this.drawBoard(); }, drawBoard() { const size = 750 / 15; // 屏幕宽度 750rpx,每格 50rpx this.ctx.clearRect(0, 0, 750, 750); this.ctx.setStrokeStyle('#ccc'); this.ctx.setLineWidth(1); // 绘制横线 for (let i = 0; i <= 15; i++) { this.ctx.beginPath(); this.ctx.moveTo(0, i * size); this.ctx.lineTo(750, i * size); this.ctx.stroke(); } // 绘制竖线 for (let i = 0; i <= 15; i++) { this.ctx.beginPath(); this.ctx.moveTo(i * size, 0); this.ctx.lineTo(i * size, 750); this.ctx.stroke(); } // 绘制星位(天元与四角) const stars = [[3, 3], [3, 11], [11, 3], [11, 11], [7, 7]]; this.ctx.setFillStyle('#000'); stars.forEach(([x, y]) => { this.ctx.beginPath(); this.ctx.arc(x * size, y * size, 3, 0, 2 * Math.PI); this.ctx.fill(); }); // 绘制已有棋子 this.data.board.forEach((row, i) => { row.forEach((cell, j) => { if (cell !== 0) { const centerX = j * size + size / 2; const centerY = i * size + size / 2; this.ctx.setFillStyle(cell === 1 ? '#000' : '#fff'); this.ctx.setStrokeStyle(cell === 1 ? '#000' : '#ccc'); this.ctx.beginPath(); this.ctx.arc(centerX, centerY, size / 2.5, 0, 2 * Math.PI); this.ctx.fill(); this.ctx.stroke(); } }); }); this.ctx.draw(); }, onTouchStart(e) { if (this.data.gameStatus !== 'playing') return; const touch = e.touches[0]; const size = 750 / 15; const x = Math.round(touch.y / size); // 注意:canvas y 对应 board 行索引 const y = Math.round(touch.x / size); // canvas x 对应 board 列索引 if (x >= 0 && x < 15 && y >= 0 && y < 15 && this.data.board[x][y] === 0) { this.pendingMove = { x, y }; } }, onTouchEnd() { if (this.pendingMove && this.data.currentPlayer === this.options.playerId) { this.socket.send({ type: 'move', x: this.pendingMove.x, y: this.pendingMove.y }); this.pendingMove = null; } }, onBoardUpdate(data) { this.setData({ board: data.board, currentPlayer: data.turn, gameStatus: this.checkWin(data.board, data.lastMove) ? 'win' : 'playing' }); this.drawBoard(); }, checkWin(board, lastMove) { if (!lastMove) return false; const { x, y, player } = lastMove; const stone = player === 'A' ? 1 : 2; const directions = [[0,1],[1,0],[1,1],[1,-1]]; // 横、竖、斜、反斜 for (const [dx, dy] of directions) { let count = 1; // 正向 for (let i = 1; i < 5; i++) { const nx = x + dx * i; const ny = y + dy * i; if (nx >= 0 && nx < 15 && ny >= 0 && ny < 15 && board[nx][ny] === stone) count++; else break; } // 反向 for (let i = 1; i < 5; i++) { const nx = x - dx * i; const ny = y - dy * i; if (nx >= 0 && nx < 15 && ny >= 0 && ny < 15 && board[nx][ny] === stone) count++; else break; } if (count >= 5) return true; } return false; } });参数说明:
- 坐标转换:
touch.y对应棋盘行(x),touch.x对应列(y),因 canvas 坐标系与棋盘数组索引方向一致; - 防抖落子:
onTouchStart记录候选位置,onTouchEnd才提交,避免滑动误触; - 胜负判定优化:只检查最后落子点的四个方向,而非遍历全盘,O(1) 时间复杂度;
setData时机:仅在收到服务端update后更新数据并重绘,确保状态绝对同步。
3.2 防误触与用户体验增强:禁用非当前玩家操作、添加加载态与音效反馈
联机游戏中,非当前玩家点击棋盘应无响应,但需视觉反馈告知“轮到对方”:
// 在 game.js 中补充 onTouchEnd() { if (!this.pendingMove) return; if (this.data.currentPlayer !== this.options.playerId) { wx.showToast({ title: '请等待对方落子', icon: 'none', duration: 1500 }); this.pendingMove = null; return; } // ... 发送逻辑 }, // 添加音效(需提前上传 audio 文件) playSound(type) { const audio = wx.createInnerAudioContext(); audio.src = type === 'move' ? '/assets/sound/move.mp3' : '/assets/sound/win.mp3'; audio.play(); }同时,在onBoardUpdate中调用this.playSound('move'),在checkWin为true时调用this.playSound('win')。音效文件需小于 1MB,格式为 MP3 或 AAC,路径在project.config.json的miniprogramRoot下。
4. 联机稳定性加固:服务端消息去重、客户端操作节流与房间状态兜底
4.1 服务端消息去重:防止网络抖动导致的重复落子
用户快速点击可能触发多次move消息,若服务端不校验,会导致同一位置落子两次(第二次覆盖第一次)。解决方案是在服务端维护每个玩家的“最后操作时间戳”:
// server.js 中修改 message 处理逻辑 ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); if (msg.type === 'move' && room.turn === playerId) { // 防重放:检查时间戳是否比上次操作晚 100ms const now = Date.now(); if (ws.lastMoveTime && now - ws.lastMoveTime < 100) { return; // 丢弃过快的操作 } ws.lastMoveTime = now; const { x, y } = msg; if (room.board[x][y] === 0) { room.board[x][y] = playerId === 'A' ? 1 : 2; room.turn = playerId === 'A' ? 'B' : 'A'; Object.values(room.players).forEach(client => { client.send(JSON.stringify({ type: 'update', board: room.board, turn: room.turn, lastMove: { x, y, player: playerId } })); }); } } } catch (e) { console.error('Invalid message:', e); } });此机制利用 WebSocket 连接对象ws的属性存储状态,无需外部存储,轻量高效。
4.2 客户端操作节流:限制用户每秒最多一次有效落子
前端增加节流层,进一步降低服务端压力:
// game.js 中 constructor() { this.moveThrottle = false; } onTouchEnd() { if (this.moveThrottle) return; this.moveThrottle = true; setTimeout(() => { this.moveThrottle = false; }, 1000); if (!this.pendingMove || this.data.currentPlayer !== this.options.playerId) { this.pendingMove = null; return; } // ... 发送逻辑 }4.3 房间状态兜底:服务端定时清理空闲房间,客户端主动退出时通知
长时间无人操作的房间应自动销毁,避免内存泄漏。服务端添加定时任务:
// server.js 末尾 setInterval(() => { for (const [roomId, room] of rooms.entries()) { const now = Date.now(); // 若房间创建超 10 分钟且无玩家,或最后操作超 5 分钟,清理 if (Object.keys(room.players).length === 0 && (now - room.createdAt > 10 * 60 * 1000)) { rooms.delete(roomId); console.log(`Room ${roomId} cleaned up`); } } }, 60000); // 每分钟检查一次 // 创建房间时记录时间 if (!rooms.has(roomId)) { rooms.set(roomId, { players: {}, board: Array(15).fill().map(() => Array(15).fill(0)), turn: 'A', createdAt: Date.now() }); }小程序端在页面卸载时主动关闭连接并通知服务端:
// game.js onUnload() { if (this.socket.ws) { this.socket.ws.close(); // 触发服务端 onClose } }服务端ws.onClose回调中,可向剩余玩家发送game over通知,但本例中因房间为空,无需额外处理。
5. 调试与验证:用 Chrome DevTools 抓包、模拟断连与跨设备真机联机测试
5.1 使用 Chrome DevTools 直接调试 WebSocket 流量
微信开发者工具的 Network 面板不显示 WebSocket 帧。正确做法是:
- 在 PC 端 Chrome 浏览器打开
chrome://inspect; - 点击
Configure...,添加localhost:8080(你的服务端地址); - 在微信开发者工具中,点击右上角
...→调试→打开调试器,选择chrome-devtools://devtools/bundled/inspector.html?ws=localhost:8080; - 在 Chrome 的
Network标签页,筛选WS,点击连接,即可查看Frames中收发的 JSON 消息。
验证重点:
- 连接建立后,是否收到
{"type":"ready","roomId":"abc123"}; - A 落子后,B 是否在
Frames中立即看到{"type":"update",...}; - 故意断开服务端,观察小程序控制台是否打印
WebSocket closed并开始重连。
5.2 模拟断连场景:强制关闭服务端进程,验证重连逻辑
在终端运行node server.js后,按Ctrl+C终止进程。此时小程序日志应输出:
WebSocket closed Reconnecting... attempt 1 WebSocket connected且重连后,若之前房间未被清理,双方仍能继续对局。若房间已被清理,则需重新创建房间。
5.3 跨设备真机联机:用同一局域网 IP 实现 iPhone 与安卓机实时对战
将服务端部署在局域网内一台电脑(如192.168.1.100:8080),两台手机连接同一 Wi-Fi:
- iPhone 小程序中,
wss://192.168.1.100:8080?room=test&player=A; - 安卓小程序中,
wss://192.168.1.100:8080?room=test&player=B; - 确保电脑防火墙放行 8080 端口,且 Node.js 服务监听
0.0.0.0:8080(而非127.0.0.1)。
此时两台设备将通过局域网直连,延迟低于 20ms,可真实体验“所见即所得”的联机感。这是验证源码能否脱离开发环境独立运行的关键步骤——很多所谓“联机源码”在此环节失败,因其服务端硬编码了localhost或未处理跨域。
注意:微信小程序要求
wx.connectSocket的url必须为wss://(HTTPS/WSS),若测试用 HTTP,需在开发者工具中勾选不校验合法域名,但真机必须使用 WSS。生产环境务必配置 Nginx 反向代理 + Let's Encrypt 免费证书,将wss://your-domain.com指向127.0.0.1:8080。
本文还有配套的精品资源,点击获取