资讯详情

五子棋联机对战开发实战:Socket.IO房间机制与状态同步

📅 2026/10/10 11:15:03 | 华诺云谱 👁 阅读
五子棋联机对战开发实战:Socket.IO房间机制与状态同步
五子棋对战的系列更到第三篇了。前两篇我们把棋盘渲染、落子交互、本地胜负判定都做完了顺手还给单机版加了个能下两手的简单AI。但说实话单机版本自己消遣可以真要拿出去跟朋友对战还是绕不开网络这一层。这一篇的核心就一件事让两个不同设备上的玩家走进同一个房间在同一张棋盘上轮流落子并且保证双方看到的局面在任何时候都完全一致。适合正在做联机棋牌类小项目、还没想清楚房间、消息、状态同步这三件事怎么落地的读者参考。我会把服务端选型、消息协议设计、房间状态机、断线重连这几个模块拆开来讲附上我实测过程中踩过的坑和最终采用的方案代码基于 Node.js 加 Socket.IO思路同样适用于其他语言和框架。1. 先把边界划清楚第三篇做的是什么不做什么1.1 前两篇打下的地基先说下前两篇做了什么不然第三篇会看得一头雾水。第一篇是棋盘和交互用 Canvas 画了一个 15×15 的棋盘处理了鼠标点击坐标到格子索引的换算黑白子轮流落上去这是整个项目的地基。第二篇把规则补全了完成五子连珠的胜负判断封装了一个checkWin(board, x, y, player)的核心函数还做了一个极简的 AI —— 其实就是遍历所有空白点找个能两头堵的位置下算不上智能但单机玩着不无聊。所以这一篇不需要再碰绘制和规则的基础逻辑直接在上面的地基上加在线两个字。如果你是从这一篇开始看的建议先回头补一下前两篇的棋盘坐标换算和胜负判断不然会卡在代码衔接上。这里的棋盘统一用二维数组表示board[y][x]0代表空、1代表黑子、2代表白子后面所有接口都按这个约定来。1.2 在线对战的核心矛盾状态同步先想明白一个本质问题单机游戏和联机游戏最大的区别是什么单机版本里棋盘数组只有一份它存在于你的浏览器内存中。你点击、你落子、你判断胜负所有操作都发生在同一个进程里不存在信息不一致这回事。但一旦变成两个人棋盘这份数据就同时存在于两个客户端手里还外加一个服务器。三个地方各有一份棋盘副本任何一份偏离了真相游戏就没法继续了。这就是状态同步问题。五子棋属于典型的回合制游戏节奏慢、操作频率低大部分时间双方都在盯着棋盘思考。它不像 FPS 那样需要每秒几十次的坐标同步也不需要对战车那种毫秒级的位置插值。五子棋每走一步棋盘上就多一个确定性的落点前后顺序严格串行——你一手我一手不存在并发操作。这意味着它的同步模型可以做得非常简单以服务端的棋盘为唯一权威所有落子都必须经过服务端校验和广播客户端只负责渲染服务端确认过的局面。很多第一次做联机小游戏的人会掉进一个坑里认为服务端就是个消息中转站客户端之间互相发消息服务端原样转达就行了。对于五子棋这种游戏这种想法是会翻车的。原因后面第四章详细讲这里先记住一个结论权威模型放在服务端客户端越笨越好。我这一篇里定下的技术栈是 Node.js Socket.IO。选它的原因很直白Socket.IO 自带了房间Room概念、断线重连的底层处理、以及现成的广播接口能帮我把精力集中在业务逻辑而不是网络细节上。如果你的项目是 Python 后端可以用 Django Channels 或 FastAPI 的 WebSocket如果是 Go用 gorilla/websocket 也很顺手。核心设计思路是通用的代码实现只是载体。2. 房间机制两个玩家如何被撮合到同一张棋盘2.1 约战码和快速匹配哪种更贴近真实需求联机对战的第一步是让两个玩家找到彼此。市面上棋牌游戏一般提供两种方式随机匹配和好友约战。五子棋这种熟人之间玩的游戏约战码方案更实用实现成本也低得多。随机匹配需要一个匹配池玩家点开始匹配后进入队列服务端定时扫描池子里的玩家凑够两个就开一局。逻辑不复杂但要做好等待体验、取消匹配、匹配超时这些边缘情况工作量不小。而且五子棋的随机匹配有个天然的尴尬万一对面是个毫无耐心的陌生人下到一半跑了你连吐槽的对象都没有。约战码方案就简单直接A 玩家点击创建房间服务端生成一个 4 位数字码比如4821屏幕上显示出来A 把这串数字发给朋友B 在输入框里填4821点击加入房间两个人就坐到了一张棋盘边上。整个过程不需要注册、不需要好友关系、不需要账号体系非常适合个人项目和小范围使用的场景。我在实际设计里还加了个小细节房间码特意选择了不含易混淆字符的数字组合去掉 0 和 1避免和 O、I 看混——虽然纯数字场景不太有这个问题但习惯保留。4 位数字总共 10000 个组合同时在线房间数远小于这个数量级碰撞概率很低。万一真的撞了服务端生成时做一次查重重试一次就行。2.2 房间数据结构与状态流转房间在服务端是什么样的我维护了一张rooms的 Mapkey 是房间码value 是一个房间对象。下面是最终定下来的结构const ROOM_STATUS { WAITING: waiting, // 等人加入 PLAYING: playing, // 对局中 ENDED: ended, // 对局结束 }; const rooms new Map(); function createRoom(hostSocket) { const roomId generateRoomId(); const room { id: roomId, status: ROOM_STATUS.WAITING, players: [], // 数组长度 0~2有先后顺序 board: Array.from({ length: 15 }, () Array(15).fill(0)), currentTurn: 1, // 1: 黑方, 2: 白方 moves: [], // 落子历史用于复盘和重连重建 winner: null, createdAt: Date.now(), }; rooms.set(roomId, room); return room; }房间的状态流转是这样的状态触发条件说明waiting → playing两名玩家都已就绪由第二名玩家加入且双方 ready 触发playing → ended某一方五子连珠 / 认输 / 超时服务端判定后写入 winnerended → waiting双方同意再来一局重置棋盘保留房间号这里有个值得注意的设计点房间的创建者不等于先手。我在最初版本里让房主固定执黑先行后来发现不够公平——朋友之间连续开几局谁创建房间谁就拿黑棋先手优势在水平接近的两个人之间影响挺大的。改成创建房间后随机分配先后手再在开局时发给双方各自执的颜色。如果要做三局两胜换先手这种进阶规则只需要在ended状态下加一个交换颜色的逻辑其实也就是把两边的 player 身份对调一下不影响其他流程。2.3 连接、落座、开局的关键代码服务端处理加入房间的完整逻辑大致是这样socket.on(join_room, (payload, ack) { const { roomId } payload; const room rooms.get(roomId); // 校验一房间必须存在 if (!room) { return ack({ ok: false, error: ROOM_NOT_FOUND }); } // 校验二房间不能已经满员 if (room.players.length 2) { return ack({ ok: false, error: ROOM_FULL }); } // 校验三房间不能在对局中 if (room.status ROOM_STATUS.PLAYING) { return ack({ ok: false, error: ROOM_PLAYING }); } // 落座push 一个 player 对象 const playerId socket.id; room.players.push({ id: playerId, color: room.players.length 0 ? 1 : 2 }); socket.join(roomId); socket.data.roomId roomId; // 通知房间内的所有成员包括刚加入的这名玩家 io.to(roomId).emit(room_update, snapshotRoom(room)); ack({ ok: true, room: snapshotRoom(room) }); });注意这里用了 Socket.IO 的 ack 回调机制客户端发join_room时携带一个回调函数服务端处理完成后通过这个回调明确告诉客户端你进来了或者失败原因是什么。为什么不用 emit 返回结果因为 emit 是单向的服务端无法知道客户端是否收到了、以及在哪一次收到。对于加入房间这种必须确认结果的操作ack 能保证客户端拿到一个确定的答复不会出现以为进房了但服务端根本没受理的灵异事件。我把加入房间变更为同步确认的语义落子则不做这种同步确认而是采用服务端广播后的异步渲染。这个区别是刻意的加入房间是低频操作玩家等待 50ms 的往返无感但落子是高频操作相对而言而且双方都需要流畅的落子反馈所以走广播模式更合适。两类操作分开设计别混用。第二名玩家加入后服务端自动广播一条game_start消息双方收到后各自初始化棋盘、显示执子颜色对局开始。3. 消息协议落子、悔棋、认输全靠这几条报文3.1 为什么 JSON 在这个量级完全够用定了房间机制接下来是消息协议。别被协议两个字吓到这里说白了就是定义清楚客户端和服务端之间每种交互用什么格式的消息来承载。很多人一上来就考虑用 Protocol Buffers 或者 MessagePack 做二进制序列化理由是性能更好、包体更小。对于五子棋这种应用我要给个明确的反对意见完全没必要。一盘棋走 200 手封顶每手消息撑死几百字节就算用 JSON 这种带冗余的格式整局下来的流量还不如打开一张网页图片大。二进制序列化的收益在这个量级趋近于零却要付出调试困难和可读性差的代价。开发联机项目时网络包能被肉眼直接看懂是极其珍贵的调试能力。我遇到过太多次客户端和服务端对不上字段名的问题比如客户端发posX服务端读xJSON 一眼就能看出问题二进制则要多走一轮反序列化才能确认。协议设计的首要原则是先能读懂再谈优化。3.2 消息清单与字段设计我的消息统一格式是这样的{ type: place_piece, roomId: 4821, seq: 17, data: { x: 7, y: 8 } }type是消息类型roomId标明消息属于哪个房间seq是客户端发送序号data是具体的业务数据。整个项目需要的消息类型不多我列一下最终版消息类型方向携带内容说明create_room客户端 → 服务端无创建房间返回房间码join_room客户端 → 服务端roomId加入房间leave_room客户端 → 服务端roomId主动离开ready客户端 → 服务端roomId准备就绪place_piece客户端 → 服务端x, y请求落子move_result服务端 → 客户端player, x, y, nextTurn, winner落子结果广播undo_request客户端 → 服务端roomId请求悔棋undo_response客户端 → 服务端roomId, accept回应悔棋请求resign客户端 → 服务端roomId认输game_over服务端 → 客户端winner, reason对局结束广播room_update服务端 → 客户端房间快照状态变化通知这里特别说一下悔棋的处理。悔棋在实体棋局里是商量着来的事在线上也得做成协商式A 发undo_request服务端转给 BB 弹出确认框B 选择同意或拒绝服务端再根据 B 的回应决定是否回滚棋盘。悔棋一定要做成双向确认服务端不能接受单方面的悔棋请求就直接回退棋子。我在早期版本里偷懒A 请求悔棋时直接在服务端把最后一步撤掉结果 B 的棋盘也跟着被撤掉。其实 B 根本不同意导致 B 的棋子被抢走当场翻了友谊的小船。协商式悔棋的完整流程是// 服务端转发悔棋请求给对手 socket.on(undo_request, (payload) { const room rooms.get(socket.data.roomId); const opponent room.players.find((p) p.id ! socket.id); io.to(opponent.id).emit(undo_request, { from: socket.id, turn: room.currentTurn }); }); // 服务端处理对手的回应 socket.on(undo_response, (payload) { const { accept } payload; if (!accept) { // 拒绝通知请求方 return io.to(socket.data.roomId).emit(undo_rejected, {}); } // 同意回滚最后一步若连续回滚两步还需要处理回合归属 const room rooms.get(socket.data.roomId); const lastMove room.moves.pop(); room.board[lastMove.y][lastMove.x] 0; const colorToUndo lastMove.player; const playerToUndo room.players.find((p) p.color colorToUndo); playerToUndo.undoAvailable false; // 一局只能悔一次防滥用 // 广播局面回滚结果 io.to(room.id).emit(undo_applied, { removed: lastMove, nextTurn: colorToUndo, // 注意悔棋后轮到被撤棋子的一方落子 }); });这里有个特别容易搞错的地方悔棋之后轮到谁下假设 A 刚下了第 20 手A 请求悔棋B 同意。撤掉第 20 手后轮到 A 重新下——因为棋权回到了刚下棋的那个人手里。但如果是 B 请求悔棋想撤回自己刚下的第 19 手撤掉之后轮到 A 重新下吗不撤掉的是 B 的棋子B 重新获得落子权。总结一句话谁被撤子谁重新落子。这个细节我用错误版本跑了好几盘棋才明白刚开始的版本直接让nextTurn等于撤子前的当前回合结果变成了撤掉对手的棋还让对手接着下逻辑完全乱了。3.3 消息序号、幂等与防重复落子seq这个字段不是摆设它解决的是消息乱序和重复的问题。WebSocket 在单连接上是保序的但在某些特殊场景下——比如客户端断线后自动重连、两条消息走了不同的网络路径——可能会出现后发的消息先到。对于回合制游戏乱序的直接后果是玩家快速连点两下服务端可能把第二次点击当成第一手处理导致棋盘错位。我的处理方式很简单客户端给每一条发送的消息递增编号seq服务端对同一个玩家的消息做序号检查只接受seq大于等于当前期望值的消息小于的说明是重复或迟到的旧消息直接丢弃。这里要注意只做丢弃还不够还需要考虑客户端发了第二手但服务端还没处理完第一手的情况。稳妥的做法是把落子同步串起来const BUFFER_SIZE 5; const pendingMove {}; socket.on(place_piece, (payload) { const room rooms.get(socket.data.roomId); if (room.status ! ROOM_STATUS.PLAYING) return; const player room.players.find((p) p.id socket.id); if (!player) return; const { x, y } payload.data; if (!isValidPosition(x, y)) return; if (room.board[y][x] ! 0) return; // 如果该玩家的落子请求积压先处理完再处理新的避免空隙 if (pendingMove[socket.id]) { return; // 上一个请求还未被广播丢弃新的 } pendingMove[socket.id] { x, y }; // 借助微任务或 setImmediate 串行处理确保顺序 processNextPendingMove(room, player); });说白了五子棋一局里不存在两个玩家同时落子的需求服务端完全可以给每个房间维护一个落子处理队列一个接一个地处理。这样比复杂的心跳时序和序号判断更省心处理顺序完全由服务端控制而不是依赖网络传输的自然顺序这是我在联调实测中总结的最省心的方案。4. 服务端状态机为什么只做转发会翻车4.1 服务端必须拥有权威棋盘现在进入本篇最重要的一节。很多新手做联机棋牌服务端代码写得跟传声筒一样收到 A 的落子消息原封不动广播给 B完事。看起来够简单但一旦加上合法性判断和胜负判定服务端不持有权威棋盘就寸步难行。举一个最典型的场景A 把棋子下在一个已经被占用的格子上。如果服务端只做转发这个非法落子会被原样转发给 BB 的客户端如果信任了这条消息棋盘上就出现两个子重叠。更严重的是A 的客户端完全可以伪造消息——手动在控制台发一条place_piece坐标指向棋盘任意位置——服务端如果只转发不校验等于允许玩家篡改棋局。服务端权威模型的核心思想棋盘的真实状态只存在于服务端内存中客户端发送的落子请求只是申请不代表事实。服务端必须用自己的棋盘副本完成三次判断坐标是否在棋盘内、目标格子是否为空、当前是否轮到该玩家落子。全部通过才更新服务端的棋盘并广播给所有客户端只要有一条不通过直接拒绝并告诉玩家原因。这套模型下任何一刻都存在两份数据客户端的展示状态和服务端的权威状态。客户端展示状态可以落后于服务端几十毫秒但永远不能比服务端更超前。这个约束换来了一个巨大的好处天然免疫作弊和篡改。4.2 落子校验与胜负判定下放落子处理的核心逻辑我在服务端这样实现function handlePlacePiece(room, player, x, y) { if (room.status ! ROOM_STATUS.PLAYING) { return { ok: false, error: GAME_NOT_STARTED }; } if (player.color ! room.currentTurn) { return { ok: false, error: NOT_YOUR_TURN }; } if (!isValidPosition(x, y)) { return { ok: false, error: INVALID_POSITION }; } if (room.board[y][x] ! 0) { return { ok: false, error: CELL_OCCUPIED }; } room.board[y][x] player.color; const move { x, y, player: player.color, seq: room.moves.length 1 }; room.moves.push(move); const won checkWin(room.board, x, y, player.color); if (won) { room.status ROOM_STATUS.ENDED; room.winner player.color; } else { room.currentTurn player.color 1 ? 2 : 1; } return { ok: true, move, won }; }checkWin函数和第二篇单机版用的是同一套以刚落下的子为中心向四个方向横、竖、两条斜线分别数连续同色棋子的数量任一条方向达到五颗即胜。在服务端跑这个判断的性能开销可以忽略不计——15×15 的棋盘全量扫描一次也就是 225 个格子几百个微秒的事连续下 200 手也才几十毫秒的累计开销。有一点要提醒胜负判定的逻辑必须同时存在于服务端和客户端。这不是重复劳动而是分层考虑。服务端判定是权威依据决定这局棋到底赢没赢客户端判定是为了用户体验——玩家落子后立刻看到自己五子连珠的动画不需要等服务器的往返延时。客户端判定可以虚报先弹动画服务端判定则实报写死结果最终以服务端的game_over广播为准。如果两者冲突——比如客户端误判——以服务端为准客户端需要回滚自己的展示状态。4.3 回合切换与时序控制回合制游戏最核心的状态是当前轮到谁。这个状态我放在了服务端字段就是currentTurn初始为 1黑方先行每次合法落子后切换。这个看起来简单的字段实际藏着一个最常见的 bug连续落子问题。玩家 A 下完第 5 手还没来得及等服务端广播A 又在本地点了第 6 手。客户端如果立即响应了这次点击界面上的棋子已经是第 6 手了但服务端的currentTurn还没切换——严格来说还在等待 B 的第 6 手。这时候服务端收到 A 的第 6 手校验player.color room.currentTurn不通过直接拒绝。客户端必须处理这种本地预渲染被驳回的情况把刚画的棋子撤回去。这个体验问题在开发初期很让人抓狂。我当时的解决方案是客户端落子后立即禁用当前玩家的棋盘交互只要玩家完成一次落子棋盘层的pointer-events或等价事件监听就关掉直到收到move_result广播并且nextTurn指向自己才重新开启。这样从交互层面就杜绝了连点两下的可能性服务端那边的NOT_YOUR_TURN错误几乎不会触发即使触发了也仅仅是防御性兜底。如果你的网络延迟比较高禁用交互会带来一点反馈迟钝的感觉玩家点了落子至少一个 RTT往返时间之后才能再操作。但五子棋本来就是思考型游戏玩家落子后绝大多数时间盯着棋盘在想下一步这个强制停拍的体验损失微乎其微。反过来如果放开交互让玩家畅快地连续落子服务端驳回时的体验损失反而更大——玩家精心策划的一手棋被系统无声拒绝比什么都难受。服务端的房间状态机我有必要画清楚这里用文字描述做代码注释waiting → (两名玩家ready) → playing playing → (checkWintrue | 认输 | 超时) → ended ended → (双方同意再来) → waiting重置棋盘 ended → (房主离开) → 销毁房间这个流转本身不复杂真正复杂的是要在每个状态转换点做边界检查。比如place_piece只能在playing状态受理join_room只能在waiting状态受理ready必须由waiting状态的玩家发出等等。状态机写清楚之后服务端的消息处理函数就变成了一个简单的当前状态 消息类型 → 下一个状态的表查找逻辑瞬间清晰起来。5. 断线重连、观战与对局恢复5.1 心跳参数怎么调在线项目里断线是必然发生的不是如果而是什么时候。用户可能开着手机去打了两分钟电话浏览器切到后台标签页被系统冻结也可能从工位走到茶水间Wi-Fi 切换导致网络中断。如果服务端一发现连接断开就立刻判负用户体验会非常糟糕。Socket.IO 自带心跳机制默认配置是pingInterval: 25000, pingTimeout: 20000——每 25 秒发一次心跳20 秒内没有收到 pong 就判定连接断开。这个默认值对棋类应用太长玩家就算什么都不做也要等 45 秒才能被判定掉线中间的等待会让另一方玩家非常不耐烦。我最终采用的配置是const io new Server(httpServer, { pingInterval: 10000, pingTimeout: 5000, });这意味着服务端每 10 秒发一次心跳5 秒没有回应就判定掉线总共 15 秒内能感知到断线。但感知到断线不等于立即判负——我在这里引入了一个断线缓冲窗口的概念玩家掉线后服务端不会马上结束对局而是把房间标记为OPPONENT_DISCONNECTED通知另一方对手掉线了请等待 30 秒。30 秒内掉线玩家重连回来对局继续超过 30 秒存活一方可以选择直接获胜或者也离开房间。为什么要 30 秒因为很多掉线其实是网络闪断几秒钟就能恢复直接判负太冤。但窗口也不能太长不然活着的玩家干等着也很痛苦。30 秒是一个折中——让掉线者来得及重连也让在线者不至于等太久。这个参数可以做成配置项我在实际使用中调过 45 秒发现等待方流失率更高最终锚定在 30 秒。5.2 重连之后的局面重建断线重连比表面看起来复杂的地方在于掉线玩家重连成功后连接 IDsocket.id变了服务端怎么知道这个新连接是刚才那个掉线玩家我的方案是重连令牌。玩家加入房间时服务端给这个连接签发一个随机 token一方掉线后客户端本地缓存这个 token。重连时客户端用 token 和服务端做一次身份确认function issueSessionToken(playerId, roomId) { const token crypto.randomBytes(16).toString(hex); sessions.set(token, { playerId, roomId, expiresAt: Date.now() 30000 }); return token; } // 加入房间成功后下发 token socket.on(join_room, ... ) { // ... 原有校验逻辑 const token issueSessionToken(socket.id, roomId); socket.emit(session_token, { token }); } // 重连时使用 token 恢复身份 socket.on(reconnect_game, (payload, ack) { const { token } payload; const session sessions.get(token); if (!session || Date.now() session.expiresAt) { return ack({ ok: false, error: SESSION_EXPIRED }); } const room rooms.get(session.roomId); const playerIndex room.players.findIndex((p) p.id session.playerId); if (playerIndex -1) { return ack({ ok: false, error: PLAYER_NOT_FOUND }); } // 将旧 player id 更新为新 socket id room.players[playerIndex].id socket.id; socket.join(room.id); socket.data.roomId room.id; // 把完整局面快照发给重连者让它能局部重建整个棋盘 const snapshot { board: room.board.map((row) row.slice()), currentTurn: room.currentTurn, playerColor: room.players[playerIndex].color, moves: room.moves, status: room.status, }; ack({ ok: true, snapshot }); io.to(room.id).emit(player_reconnected, { playerId: socket.id }); });这里有个关键操作用board.map(row row.slice())做深拷贝。如果不做拷贝直接传room.board客户端拿到的是服务端数组的引用——虽然 Socket.IO 序列化时会生成一份新的 JSON 数据理论上不会有共享引用的问题但我在早期版本里传递过服务端内部对象给客户端后来在调试时发现客户端居然能改到服务端的数据因为本地反序列化后的对象虽然不共享但某些操作习惯性直接赋值回去从此养成了快照必拷贝的习惯。服务端发给客户端的任何数据都应该是一份独立的、不带服务端内部引用的快照。重连者拿到完整棋盘快照后直接把整个棋盘重新绘制一遍就行。不需要逐手重放动画那是复盘功能的事。这里我要啰嗦一句快照重建比逐手重放简单太多而很多新手会本能地选择重放——因为他们觉得把历史步数一条条发给玩家看起来更还原。但历史重放需要处理动画时序、中间状态、异常中断复杂度是指数级上升。对于正在进行的棋局快照重建就是最优解没有之一。5.3 观战模式的实现成本比想象中低观战是五子棋里很有价值的轻量功能——两个人下棋第三个人想围观记谱这几乎是棋类天然的需求。而且观战在技术上几乎零成本。我实现观战只用了十几行代码在join_room里允许role: spectator的请求第三名及以后的连接以观战者身份加入同一个房间服务端把room_update和move_result广播给他们但他们不能发送place_piece和undo_request。区别只在权限校验数据流完全复用。真要做得精细可以给观战者提供只看棋盘、不显示思考缓存的配置避免观战界面被中间态数据干扰。但这是优化项不急着做。核心思路是观战者订阅的本质上就是房间状态变更事件和玩家共享同一条广播通道只限制写权限阅读权限完全放开。6. 联调实测三个让我挠头的坑与排查链路6.1 坑一棋盘镜像翻转根因在坐标约定第一篇里棋盘坐标是board[y][x]渲染用的也是这个顺序。但联调时发现A 的客户端在服务端显示的是正常的B 那边看到的棋盘却是左右镜像的。排查了很久才发现问题不在网络而在数据序列化。我在一个地方用了{ x: col, y: row }的字段命名另一个地方却用了{ row: x, col: y }——在 A 的代码里x 一直代表行坐标在 B 的代码里x 却代表列坐标。数据传到服务端后校验函数按自己的约定读xA 发来的行号被当成了列号棋子的位置就翻转了。排查链路是这样的先在两个客户端同时打印收到的move_result对比同一个x值在两个界面上渲染的位置——发现 A 的x7渲染在第 7 列B 的x7渲染在第 7 行。于是追到渲染函数再追到数据字段命名最后发现是两套代码对坐标轴的定义不一致。这类问题的根治办法只有一个在全项目范围内统一坐标语义并且只在唯一的地方做一次坐标转换。我最后的做法是定义了一个Point对象客户端所有底层操作都直接用Point(row, col)序列化时用{ r: row, c: col }的短命名字段名越短越不容易出现我写的是 row 还是 y的混淆。同时我在服务端的入口处加了一个断言函数如果收到的坐标超出0~14的范围直接打日志并拒绝这个防御性校验在联调阶段救了我至少三次。6.2 坑二快速落子导致的双端不一致第二个坑出现在网络条件比较差的实测环境。A 连点了两下第一手落在(7,7)第二手落在(8,7)。由于 A 的客户端先本地渲染了这两手A 的界面看起来棋盘是好的但服务端只受理了第一手因为校验流程设置了落子互斥第二手被拒绝了。A 的界面还停留在两手都画上去的状态服务端的棋盘却只有一手——双端就此分叉。我最初的修复思路是客户端也做一套完整的落子校验确保发出去的请求必然合法。但后来发现这治标不治本——再完备的客户端校验也防不住服务端并发处理顺序和本地渲染顺序不一致带来的分叉。最终方案做了一次架构上的收敛客户端不再先落子后确认而是确认后落子。具体来说玩家点击棋盘客户端把格子高亮显示为待确认落子但不真正画上棋子客户端发送place_piece给服务端服务端校验通过并广播move_result客户端收到move_result后才真正渲染这颗棋子并切换到等待对方落子的状态。这套流程会让每次落子都多一个人均网络往返的延迟但换来的是**客户端永不撒谎**——画上去的每一颗棋子都是服务端确认过的。对于五子棋这种节奏多一个 RTT 的等待几乎无感。如果连这个延迟都不能忍就要走乐观渲染 回滚的路线那是对客户端状态管理非常大的复杂度提升普通项目不值得。我还在这里补充一个防御性设计服务端广播move_result时带的seq字段客户端可以用来校验自己收到的消息顺序。如果发现seq跳号说明有消息丢了客户端应该主动请求一次全量快照把可能的局部不一致直接抹平。这个自愈机制我在后面复盘功能里也复用了。6.3 坑三刷新页面直接被判负这个坑虽然是测试时发现的但它几乎一定会在真实用户身上发生玩家在浏览器里按了一下 F5 刷新页面整个 Socket.IO 连接断开服务端立刻进入断线处理30 秒缓冲窗口开始倒计时。如果玩家刷新页面速度快能在 30 秒内重新进来还好但页面刷新往往伴随着重新加载静态资源、重新建立连接、重新输入房间码这一通操作下来经常超过 30 秒于是玩家回来发现自己已经输了。这里的核心矛盾是服务端的断线缓冲窗口用的是服务端时间可玩家从断线到重连之间的耗时很大一部分是浏览器重新加载页面的时间服务端对此一无所知。而且刷新页面意味着客户端的 JS 上下文完全销毁连重连 token 都可能在内存里丢了——如果 token 只存在内存中刷新后 token 就没了重连逻辑根本无法触发。修这个坑我做了两件事。第一token 持久化到 localStorage页面刷新后从 localStorage 读出来做身份恢复token 的过期时间与服务端一致即可。第二把立即开始 30 秒倒计时改成检测到断线后先宽限 10 秒再开始 30 秒倒计时——因为刷新页面产生断线后Socket.IO 在 15 秒内pingInterval pingTimeout才会真正感知到断线也就是说服务端感知断线本身就有延迟。如果一感知到就倒计时玩家实际被剥夺了断线感知延迟这段时间。调整后的时间线是断线发生 → 15 秒后服务端感知 → 开始 10 秒宽限期 → 再进入 30 秒缓冲窗口。合计给了玩家 55 秒左右的恢复时间刷新页面基本绰绰有余而正常对局中等待方最多也就多等十几秒。顺带一提这里还需要处理一个边缘情况token 里记录的playerId是旧的 socket.id重连后 socket.id 会变所以要像前面 5.2 节那样用sessions存映射而不能直接拿 socket.id 当玩家永久身份。socket.id 是连接级身份不是玩家级身份。这句话值得刻在联机开发的屏幕贴纸上。6.4 这一篇做完之后在线五子棋还能往哪些方向长把房间、消息、状态机、重连这几块跑通之后在线五子棋的核心已经非常完整了。我复盘时发现后续有四个扩展方向性价比最高按实现难度从低到高排序第一对局复盘。服务端已经存了完整的moves数组每步包含坐标和玩家这本身就是一份天然的棋谱。做个观战回放页面把moves按时间逐手渲染就是最简单的复盘功能。我打算在后续版本里加一个spectate_replay消息类型响应时就返回整份 moves。第二再来一局与换先手。在ended状态下双方都发rematch_ready服务端重置房间棋盘交换颜色重新开局。这个功能其实只需要复用resetRoom(room)方法注意同时清理掉winner和moves就行。第三简易天梯分。给每个客户端本地存一个wins计数服务端在对局结束时更新。不需要数据库一个 JSON 文件就能持久化适合个人项目自娱自乐。第四AI 托管。在断线缓冲窗口结束后如果掉线方没有重连存活方可以选择由 AI 接管掉线方。这样既能继续把棋下完又不至于让等待方干瞪眼。但 AI 接管需要把第二篇写的那个简单 AI 逻辑搬到服务端执行同时要让 AI 的每一步也走一遍落子校验和广播流程整体改造量不小属于可选进阶项。就我个人在实际项目里的体会来说这一篇最值钱的经验不是某个具体函数怎么写而是服务端权威模型 客户端确认后渲染这套组合拳。它让棋类联机的复杂度下降了一个量级不用考虑状态合并、不用处理冲突回滚、不用做复杂的全局时钟只需要把服务端状态机维护好双端一致性就自然保证了。后续如果你想把五子棋升级成象棋、围棋或者斗地主架构完全不用动换的只是棋盘大小、规则判断和消息类型。按这个思路下一轮迭代我会先补复盘和观战到时候再写一篇实战记录分享出来。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑