ARTICLE DETAIL

资讯详情

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

在线五子棋实时对战:WebSocket通信与状态同步实践

在线五子棋实时对战:WebSocket通信与状态同步实践 在线五子棋对战做到第三篇棋盘基础交互早就通了落子也能走服务端接口也能通。但真正要喊朋友来下一盘发现还差得远——一落子就卡顿、两边棋盘对不上、刷新一下就全没了这些才是“在线对战”真正要命的细节。这一篇我重点讲实时对战部分的设计与实现包括 WebSocket 通信、房间匹配和状态同步还整理了联调阶段踩过的坑希望能帮你少走几周弯路。1. 系列回顾与第三章的定位1.1 前两章做到什么程度按系列惯例前面两章大概完成了这些事15x15 棋盘的前端渲染、黑白棋子的绘制、鼠标点击落子的基础交互、本地单机模式下的胜负判断以及服务端基本的 HTTP 接口比如创建对局、查询棋局状态。这个阶段跑起来很流畅你点一下棋子出来五连了弹个提示一切都很正常。但一到“在线对战”就会露馅——你用 HTTP 拉一次棋局状态对手落子你根本不知道得手动刷新你落子之后如果先校验再提交网络一慢 UI 就卡死双方同时点同一个位置谁赢谁输说不清楚。所以第三篇的目标非常明确把单机可玩的五子棋改造成真正能两人实时对战的在线版。1.2 这一章要解决什么问题这一章不是返工而是在已有基础上加一层“实时同步骨架”。核心要解决四个问题双方如何建立连接并传递落子消息服务端如何保证棋局状态的唯一性和权威性匹配、房间、掉线、超时这些对局生命周期怎么管前端如何在没有明显延迟感的前提下与服务端状态保持一致我在这篇里选的技术方案偏轻量前端用原生 JavaScript Canvas服务端用 Node.js 的 ws 库做 WebSocket 网关存储先用进程内存不急着上 Redis。这样做的好处是逻辑直观调试方便等并发量真大了再迁移也不迟。2. 实时对战的核心WebSocket 通信设计2.1 为什么选 WebSocket 而不是轮询很多刚接触在线对战的人会习惯性用轮询每两秒请求一次棋局状态有变化就刷新棋盘。这个方法在 Demo 里能跑但问题很明显——延迟高、请求冗余、服务端压力大。对手落子后你最少要等一个轮询周期才能看到体验很糟糕。WebSocket 的优势在于全双工通信连接建立后双方都可以随时推消息不用反复握手。而且五子棋落子本身是很低频的消息一局 30 分钟也就几十条数据WebSocket 完全能轻松扛住。用生活类比来解释HTTP 轮询像你去车站问“车到了吗”每隔几分钟跑一趟WebSocket 像车站直接给你发短信“车到了”。后者信息更及时成本也更低。2.2 通信协议与消息格式设计我见过不少项目在联调阶段才吵“消息格式不统一”前端发{x: 1, y: 2}后端回{position: 1,2}解析起来非常痛苦。所以建议一开始就把协议定死统一用 JSON 对象包含type、payload、requestId三个字段。// 客户端落子消息 { type: move, requestId: uuid-xxx-001, payload: { x: 8, y: 8 } }// 服务端广播落子结果 { type: move_ack, requestId: uuid-xxx-001, payload: { x: 8, y: 8, player: black, turn: white, legal: true } }type用于区分消息类型常见的我列一下join_room加入房间match_start匹配成功双方就座move客户端落子请求move_ack服务端确认落子结果game_over对局结束heartbeat心跳保活reconnect断线重连标识requestId的作用非常重要它可以把“请求”和“服务端回执”关联起来。前端拿到一条move_ack可以通过requestId确定是自己哪一步操作的结果避免消息乱序时搞混。另外建议在消息里预留version字段哪怕现在没用。因为后续一旦要加悔棋、观战或聊天协议版本不一致会带来很大的迁移成本。3. 棋盘逻辑与落子判定3.1 棋盘数据模型棋盘本质上就是一个 15x15 的二维数组0 表示空1 表示黑棋2 表示白棋。// 棋盘状态 const board [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // ... 共 15 行 ];核心原则是客户端和服务端各保存一份棋盘状态但服务端的数据是“权威数据”客户端的数据只是“展示数据”。这个区分直接决定了对局公平性和状态一致性。后面第六节我会详细说联调时的坑绝大多数都源于这个原则没想清楚。3.2 落子流程与合法性校验在线对战场景里不能像单机模式那样点了就落子。标准的流程应该是客户端发送move消息带上坐标。服务端校验当前是否轮到这个玩家、坐标是否在 0~14 范围内、目标位置是否为空、游戏状态是否还是 PLAYING。校验通过后服务端更新自己的权威棋盘切换执子方。服务端把move_ack广播给房间内双方带上最新落子坐标和下一步轮到谁。客户端收到move_ack后才真正在画布上渲染新棋子。这里有个很容易犯的错误客户端先本地画棋子再等服务器确认如果服务器说“不能落”再回滚。实际上这个方案会让客户端逻辑复杂一倍而且存在短暂的状态不一致窗口。我的建议是忍一下网络延迟等服务器确认再渲染体验反而更干净。3.3 五连胜负判定算法五子棋判胜的核心逻辑就是看刚落下的那个子在横、竖、两个对角线四个方向上能否形成连续的五个同色棋子。实现起来并不复杂不需要全盘扫描只需要从落子点出发向两个相反方向分别数连续同色棋子数加起来再减去落子点本身即可。function checkWin(board, row, col) { const player board[row][col]; const directions [ [0, 1], // 横向 [1, 0], // 纵向 [1, 1], // 右下对角线 [1, -1], // 左下对角线 ]; 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 15 || nc 0 || nc 15) break; if (board[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 15 || nc 0 || nc 15) break; if (board[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }这里有几个细节值得注意count 5而不是count 5因为长连超过五个连续同色在一些民间规则里也算赢如果你用的是专业规则可以自行调整。判断顺序务必放在“落子后、广播前”如果服务端发现某个玩家已经形成五连先进入game_over流程再进行广播否则客户端可能多走一子。4. 房间匹配与游戏状态机4.1 匹配机制实现思路在线对战的匹配机制按复杂度可以分为两类第一类是朋友邀请制玩家创建房间把房间号发给好友好友输入房间号加入。实现简单对小型项目非常友好。第二类是随机匹配制玩家点击“开始匹配”服务端把他放入等待队列一旦等待队列里凑够两个人就自动创建房间。我在这个项目里先实现了第二类因为随机匹配更能体现“在线对战”的感觉。服务端维护一个简单的等待队列const waitingQueue []; function joinMatchQueue(playerId, ws) { waitingQueue.push({ playerId, ws }); if (waitingQueue.length 2) { const p1 waitingQueue.shift(); const p2 waitingQueue.shift(); createRoom(p1, p2); } }这个版本不支持权重大小、段位、胜负率但对于和朋友玩、内部测试来说足够了。要是你想做天梯匹配后续得引入 Redis 队列和一个真正的评分算法复杂度会高不少。4.2 游戏状态机设计对局生命周期一定要用状态机管理不然各种边界情况会把你逼疯。我定义的房间状态如下状态含义可进入的下一个状态WAITING等待对手PLAYING / ABANDONEDPLAYING对局进行中FINISHED / ABANDONEDFINISHED正常分出胜负无ABANDONED房间作废无状态转换逻辑写在服务端客户端只是被动接收。为什么非要状态机因为很多边界场景需要它来判断是否执行操作。比如玩家在 PLAYING 状态下发送move消息可以被正常处理但如果房间已经 FINISHED再收到move就直接拒绝。还有一个细节黑棋先手还是白棋先手必须在房间创建时确定并且写入房间状态快照。我通常在匹配成功时随机分配并把currentTurn初始化为 black。4.3 超时与掉线处理在线对战最大的敌人不是对手太强而是网络不稳定或者玩家中途跑路。我先加了落子超时机制每一步限时 30 秒超时判负。实现方式不是给每个房间开一个setTimeout定时器而是在玩家落子时记录时间戳在服务端的 tick比如每 5 秒一轮里统一检查是否超时。这样实现简单也能避免定时器泄漏问题。if ( game.turn white Date.now() - game.lastMoveAt 30 * 1000 ) { // 白方超时黑方胜 finishGame(game, black, timeout); }掉线处理的思路是客户端每 15 秒发送一个heartbeat消息服务端如果连续 3 个心跳没收到就标记玩家掉线。此时房间状态变成 ABANDONED 或者直接判负取决于你希望的对局规则。我更倾向于给玩家一个 60 秒的重连窗口超过才判负这样体验更温和。5. 前后端联调与体验优化5.1 前端落子与后端校验的同步联调阶段最重要的就是“全链路时序对得上”。我画一个文字版的时序描述这不是图就是步骤顺序玩家 A 点击棋盘空白位置。前端先在本地把棋盘临时锁定防止连点。前端发送move消息给服务端。服务端校验通过更新棋盘状态。服务端把move_ack同时推送给玩家 A 和玩家 B。玩家 A 的前端收到move_ack渲染黑棋解锁棋盘切换为白棋等待。玩家 B 的前端收到move_ack渲染黑棋提示轮到白棋操作。这里特别要提防“重复提交”问题。玩家手一抖连续点了两下同一个位置就会往服务器发送两条move消息。解决方式分两层第一层前端在收到move_ack之前锁定棋盘交互不允许继续落子。第二层服务端即使收到重复move也要先检查目标坐标是否为空以及当前房间状态是否合法。如果位置已经被占直接返回legal: false不会被覆盖写入。5.2 断线重连与对局恢复HTTP 接口时代不存在“断线重连”的问题因为每次请求都是独立的。但 WebSocket 长连接一旦断开就得设计恢复机制。我的做法是客户端建立连接后先发送join_room带上身份标识一个playerId和房间号。服务端把playerId和 WebSocket 连接对象绑定。如果连接断开服务端保留房间信息 60 秒。客户端重连后再次发送join_room服务端检查房间是否还存在以及该playerId是否属于这个房间。若校验通过服务端下发完整棋盘快照、当前轮到谁、剩余时间。关键点在于不要试图把“连接”和“玩家身份”绑定死。玩家可能换网络、换设备连接会变但身份和房间不能换。所以服务端要维护一个内存映射// playerId - { roomId, ws } const playerRegistry new Map();这样即使ws对象变了只要playerId没变玩家就能回到原来的对局中。5.3 防连点、防抖动和视觉效果优化在线五子棋的体验不光在逻辑还在手感。前端落子后的短暂卡顿哪怕只有 200ms也会让玩家觉得“卡”。有个小技巧客户端不用等服务端广播再渲染而是在收到move_ack前先显示一个半透明的“待确认棋子”服务端确认后变成实心棋子。如果服务端拒绝半透明棋子消失。这就像你在手机上发消息先显示“发送中”再变成“已发送”体感顺畅很多。另外几个视觉细节也非常影响观感最后一步落子位置用红色小方框高亮玩家一眼就能看到自己刚下在哪。棋盘要支持响应式缩放不然手机端显示不全。落子音效用轻一点的敲击声不要太刺耳。6. 实战踩坑与排查实录6.1 双端棋盘不一致的经典原因我最早做联调时棋盘经常出现一边有子、一边没子的情况。查了很久才发现问题出在“客户端本地落子再同步”的架构上。最初的设计是玩家 A 点击棋盘后前端先渲染棋子再发送move给服务端服务端转发给玩家 B。看起来没问题但一旦服务端因为某种原因拒绝或延迟玩家 A 本地可能已经渲染了棋子而棋盘上实际坐标已被占用或者 B 端根本没收到最终两边不一致。解决方案就是前面说过的统一以服务端广播的move_ack为准客户端只做“展示”不做“决策”。这个改动虽然让落子到渲染之间多了一条网络往返但换来的是一致性和公平性值。6.2 并发落子的竞争问题还有一个让我头疼的问题是如果双方几乎同时点击棋盘服务端会收到两条move消息由于 Node.js 单线程事件循环的影响处理逻辑通常不会真正并行但如果中间有耗时操作比如写日志、查数据库还是可能产生交叉。我用的解法非常简单粗暴每个房间一个 Promise 队列所有针对该房间的操作都串行执行。const roomQueue new Map(); function enqueueRoomTask(roomId, task) { if (!roomQueue.has(roomId)) { roomQueue.set(roomId, Promise.resolve()); } const prev roomQueue.get(roomId); const next prev.then(task); roomQueue.set(roomId, next); }这样就不会因为两个玩家同时落子导致状态错乱。当然更严谨的做法是给每次落子带上递增序号服务端只接受比当前序号大 1 的消息。但对于这个规模的项目队列方案已经足够稳定。6.3 重连后变成“幽灵观战人”重连机制上线后我又发现了一个奇怪现象某玩家掉线重连后明明他就在房间里也能收到棋盘快照但系统提示他“您正在观战”无法落子。排查后发现根因是房间里的玩家身份列表没有更新。原来我的reconnect流程只校验了playerId是否存在但没把它重新绑定到新的 WebSocket 连接上。导致后续广播move_ack时服务端仍然对着旧的、已经断开的连接推送新连接收不到自然以为自己只是观战者。修法是重连接收时需要执行三步——校验身份、更新playerRegistry映射、通知房间内另一端“某某已重连”。这一段逻辑建议专门写成一个recoverPlayerSession函数不要分散在join_room和reconnect两个函数里。6.4 常见问题速查表现象可能原因处理建议棋盘两边不一致客户端本地先渲染再广播改为等move_ack再渲染玩家无法落子但能看到棋盘重连时未更新 WebSocket 映射重新绑定 playerId 到新连接一局结束后还能提交落子房间状态未从 FINISHED 切换服务端拒绝所有非 PLAYING 状态的 move连接频繁断开没有心跳保活机制加 heartbeat 消息15 秒一次双方同时落子成功并发处理不足给房间加 Promise 队列串行执行玩家重连后收不到历史棋盘未下发完整棋盘快照重连时发送board_snapshot消息超时判负无提示服务端广播 game_over 时未带原因payload 增加 reason 字段写在最后在线五子棋做到这里已经能支撑一段稳定的双人对战了。我自己在实测中用两台设备同时玩网络环境还特意开了一个弱的 Wi-Fi 来模拟抖动断线重连走了好几轮没有再出现状态错乱的问题。以我做了几个小游戏项目的经验来说在线对战项目的复杂度真的不在 AI 和画面上而在状态一致性。谁先落子、谁超时、谁掉线服务端必须有一个绝对的唯一标准。你越早确立“服务端持有最终权威状态、客户端只做展示层同步”这个原则后面就越省心。如果后续你还想让这盘棋更有意思有两个方向可以延伸一个是观战模式房间内允许第三个人加入只读推送客户端订阅广播但不发送落子另一个是对局回放把每一步move_ack记录下来结束后按时间轴重放。这两个功能在半年前我做另一个棋牌项目时都遇到过离了服务端权威状态设计根本没法做。希望这篇能帮你把地基打牢少踩几个我已经踩平了的坑。
返回列表