ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序五子棋联机实战:WebSocket状态同步与断连重连

微信小程序五子棋联机实战:WebSocket状态同步与断连重连 简介本资源是一套基于微信小程序实现局域网联机对战的五子棋游戏完整源码面向小程序开发者及前端学习者解决单机游戏向多人实时交互场景延伸的技术实践难题。压缩包共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 参数传递身份小程序连接时传?roomabc123playerA避免登录态校验复杂度内存存储棋局状态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服务端需响应pongws库默认开启 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-idchessCanvas bindtouchstartonTouchStart bindtouchendonTouchEnd stylewidth: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对应棋盘行xtouch.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?wslocalhost:8080在 Chrome 的Network标签页筛选WS点击连接即可查看Frames中收发的 JSON 消息。验证重点连接建立后是否收到{type:ready,roomId:abc123}A 落子后B 是否在Frames中立即看到{type:update,...}故意断开服务端观察小程序控制台是否打印WebSocket closed并开始重连。5.2 模拟断连场景强制关闭服务端进程验证重连逻辑在终端运行node server.js后按CtrlC终止进程。此时小程序日志应输出WebSocket closed Reconnecting... attempt 1 WebSocket connected且重连后若之前房间未被清理双方仍能继续对局。若房间已被清理则需重新创建房间。5.3 跨设备真机联机用同一局域网 IP 实现 iPhone 与安卓机实时对战将服务端部署在局域网内一台电脑如192.168.1.100:8080两台手机连接同一 Wi-FiiPhone 小程序中wss://192.168.1.100:8080?roomtestplayerA安卓小程序中wss://192.168.1.100:8080?roomtestplayerB确保电脑防火墙放行 8080 端口且 Node.js 服务监听0.0.0.0:8080而非127.0.0.1。此时两台设备将通过局域网直连延迟低于 20ms可真实体验“所见即所得”的联机感。这是验证源码能否脱离开发环境独立运行的关键步骤——很多所谓“联机源码”在此环节失败因其服务端硬编码了localhost或未处理跨域。注意微信小程序要求wx.connectSocket的url必须为wss://HTTPS/WSS若测试用 HTTP需在开发者工具中勾选不校验合法域名但真机必须使用 WSS。生产环境务必配置 Nginx 反向代理 Lets Encrypt 免费证书将wss://your-domain.com指向127.0.0.1:8080。本文还有配套的精品资源点击获取
返回列表