资讯动态

WebSocket五子棋对战游戏开发:协议、心跳与重连实战

发布时间:2026/10/8 19:52:43 来源:尧图企业网站定制
简介基于WebSocket的在线五子棋对战游戏设计源码完整实现了用户注册、登录、对战匹配、实时对战与实时聊天五大功能模块适合正在学习C、CSS、HTML和JavaScript的开发者用于个人实践与项目复盘。资源共33个文件包体仅6.13MB文件类型以C头文件hpp、前端页面CSS/HTML/JS、数据库脚本SQL及配置文件为主服务端与网页端结构清晰便于按模块拆解学习。目前已有406人学习下载。通过这份源码读者可以重点研究WebSocket双向通信在实际游戏对战中的调用方式理解服务端如何维护房间与匹配逻辑同时参考前端页面的事件响应和界面组织。配套的db.sql建表语句、makefile构建脚本和readme说明能帮助快速搭建运行环境复盘完整的项目组织思路对希望从零设计联机小游戏的开发者具有不错的参考价值。1. 基于WebSocket的在线五子棋对战游戏先解决“两个人怎么同步”的问题约好和朋友远程下一盘五子棋你发一个网页链接对方打开进房间轮流落子棋盘实时同步。这个场景用 HTTP 轮询做也能跑——每 2 秒拉一次棋盘状态但延迟、服务器压力、代码复杂度全都不对味。基于 WebSocket 的在线五子棋对战游戏设计源码核心就一件事把双人实时对弈的“同步”问题用一条长连接干净地解决掉。落子、胜负、悔棋、重连全部通过 WebSocket 消息推送不用轮询不用纠结请求顺序。适合想实战 WebSocket 的初中级开发者、做课程设计的学生以及想搞懂实时对战系统怎么设计的一线工程师。我按自己做过的一套方案讲清楚协议怎么定、后端怎么写、前端怎么接、坑在哪。2. 先定协议再写代码五子棋对战的 WebSocket 消息结构与关键技术选型2.1 为什么这类实时对战默认选 WebSocket而不是轮询或 SSE先做个对比你就明白为什么 WebSocket 是这个场景的默认答案。方案实时性服务器开销双向通信浏览器支持HTTP 轮询取决于轮询间隔秒级每次请求带完整 HTTP 头空闲时也在空转只能客户端主动问全支持SSEServer-Sent Events好服务端可主动推比轮询低单向客户端不能通过同一连接发数据全支持IE 除外WebSocket毫秒级连接建立后双向即时一个 TCP 连接头部开销极小双向客户端和服务端随时互发现代浏览器全支持五子棋对战的痛点在于落子是高频双向交互一方落子后另一方要立刻看到而且客户端要发送落子坐标给服务端服务端也要广播给另一方。这类“双向、低延迟、持续连接”的需求轮询做起来既慢又费资源SSE 做不了反向推送。WebSocket 的优势不只是延迟低更在于连接建立后消息本身就是一个个独立帧天然适合“落子、心跳、悔棋、重连”这类事件驱动的游戏协议。选择实现语言时Node.js 的ws库是常见做法因为ws在 GitHub 上维护活跃、API 简洁、单机并发不错而且事件循环模型和游戏这种消息驱动的场景很匹配。用 Python 的websockets库或 PHP 的 Swoole 也能做但如果你要快速拿到一套能跑通的源码Node.js ws是阻力最小的一条路径。2.2 消息格式用一份 JSON 定义全部对战指令WebSocket 的消息本质是字节流你需要自己约定一套协议。常见做法是统一用 JSON 字符串每个消息包含type和data两个字段。type是消息类型data是对应的数据负载。我一般会这样定义// 消息类型定义TypeScript 描述实际代码用普通对象 type GameMessage | { type: join, data: { roomId?: string } } // 加入房间roomId 为空则新建 | { type: move, data: { x: number, y: number } } // 落子x/y 是棋盘坐标0-14 | { type: heartbeat } // 心跳 | { type: sync, data: SyncPayload } // 重连后的全量状态同步 | { type: restart, data: {} } // 再来一局 | { type: error, data: { message: string } } // 服务端返回错误这段定义的逻辑很直接join负责进房间move是核心对局指令heartbeat用于维护连接存活sync是断线重连的后悔药。restart用来在同一房间内开启新对局省得重新建房。参数说明x和y从 0 开始最大 14对应 15×15 的棋盘roomId是服务端生成的 6 位短字符串便于玩家之间口头或聊天室传播。为什么不用数字 ID短字符串不容易输错用户体验好。每个消息都要有明确的处理路径服务端在入口处统一按type分发不要允许客户端直接操作服务端内部状态。这一点后面写后端时你会看到所有校验都放在服务端客户端只是发送“意图”。2.3 房间模型两个玩家一间房状态怎么放在线五子棋对战不像大型 MMORPG 有复杂的世界管理一个房间最多两个玩家但房间状态本身有好几层玩家信息、棋盘数组、当前轮到谁、对局状态等待中/对局中/已结束。我常用的数据结构是这样的// 房间数据结构Node.js 后端 const rooms new Map(); // rooms: roomId - { // id: ABC123, // players: [ws, ws], // 长度为2空位是 null // board: Array(15).fill(null).map(() Array(15).fill(0)), // turn: 0, // 0 表示 players[0] 的回合 // status: waiting | playing | over // winner: null | 0 | 1 // }每个连接对象ws上还要挂两个字段ws.roomId表示当前在哪个房间ws.playerIndex表示在房间里的位置0 或 1。这样收到消息时服务端能快速定位这条消息属于哪个房间、是哪个玩家发的。为什么用Map而不是普通对象因为频繁地增删房间Map的键支持任意类型且避免原型链污染遍历也方便比如做心跳清理时可以遍历所有房间。对这套规模的应用Map在语义和性能上都比对象字面量合适。房间生命周期要注意当第二个玩家加入时status从waiting变为playing并且要广播给两个玩家“对局开始”当某个玩家断开时房间不能立即销毁——要保留状态等待重连否则断线重连的体验全毁了。我一般用 60 秒的“保留期”期间对方不能退出超时后房间才销毁。3. 后端源码核心Node.jsws 房间管理 落子校验与胜负判定3.1 WebSocket 服务器初始化与连接管理后端源码的核心逻辑不复杂但关键细节决定稳定性。先看最小可运行的服务器初始化const { WebSocketServer } require(ws); const http require(http); const server http.createServer(); const wss new WebSocketServer({ server, path: /game }); wss.on(connection, (ws, req) { // 每个连接挂上独立的玩家ID和房间状态 ws.playerId p_${Date.now()}_${Math.random().toString(36).slice(2, 8)}; ws.roomId null; ws.playerIndex null; ws.isAlive true; // 收到 pong 时标记连接存活心跳机制的一部分后面细讲 ws.on(pong, () { ws.isAlive true; }); ws.on(message, (raw) { try { const msg JSON.parse(raw.toString()); handleMessage(ws, msg); } catch (err) { ws.send(JSON.stringify({ type: error, data: { message: invalid message } })); } }); ws.on(close, () { handleDisconnect(ws); }); }); server.listen(8080, () { console.log(WebSocket server listening on ws://localhost:8080/game); });path: /game是 WebSocket 的握手路径客户端连接时 URL 要写ws://host:8080/game。这样做的目的是让同一个端口还能跑 HTTP 服务比如静态页面路径隔离互不干扰。玩家 ID 不用自增数字因为一旦服务端重启自增 ID 会重复客户端重连时会认错人。时间戳加随机串的方式虽然不优雅但够用而且不会碰撞。3.2 消息路由与房间处理先入房再落子handleMessage是消息分发的总入口。注意这里每个case都是同步处理没有任何异步操作穿插这样能保证同一房间的消息严格有序——这是后面避坑章节里“消息乱序”问题的根本解法。function handleMessage(ws, msg) { switch (msg.type) { case join: handleJoin(ws, msg.data); break; case move: handleMove(ws, msg.data); break; case heartbeat: ws.send(JSON.stringify({ type: heartbeat, data: { ok: true } })); break; case restart: handleRestart(ws); break; case sync: handleSync(ws); break; default: ws.send(JSON.stringify({ type: error, data: { message: unknown type: msg.type } })); } }join的处理逻辑是房间管理的核心也是最容易出 bug 的地方function handleJoin(ws, data) { // 已经在房间里的不能重复加入 if (ws.roomId) { ws.send(JSON.stringify({ type: error, data: { message: already in room } })); return; } const roomId data.roomId || generateRoomId(); let room rooms.get(roomId); if (!room) { // 新房间创建者坐 players[0] room { id: roomId, players: [ws, null], board: Array(15).fill(null).map(() Array(15).fill(0)), turn: 0, status: waiting, winner: null }; rooms.set(roomId, room); ws.roomId roomId; ws.playerIndex 0; ws.send(JSON.stringify({ type: joined, data: { roomId, index: 0, status: waiting } })); return; } // 房间已满 if (room.players[1] ! null) { ws.send(JSON.stringify({ type: error, data: { message: room full } })); return; } // 第二个玩家加入对局开始 room.players[1] ws; ws.roomId roomId; ws.playerIndex 1; room.status playing; room.turn Math.random() 0.5 ? 0 : 1; // 随机决定先手 broadcastToRoom(room, { type: game_start, data: { roomId, players: [room.players[0].playerId, room.players[1].playerId], turn: room.turn } }); }这里的逻辑说明几个关键决策第一创建房间和加入房间用同一个join消息靠roomId是否存在来区分。前端不用维护“是建房还是加入”两套请求逻辑简化了客户端代码。第二generateRoomId()生成长度 6 的随机字符串用大写字母加数字排除易混淆的0/O、1/I。第三这里turn是随机的让后加入的玩家也有 50% 概率先手。如果固定先手那谁先进房间谁永远先手对局趣味性会下降。3.3 落子与胜负判定权威校验必须放在服务端落子是最核心的动作。为什么服务端必须校验一切因为在线对弈和本地单人游戏不同玩家可能开两个浏览器窗口用自动化脚本连发非法落子若服务端不校验棋盘状态就会错乱最终双方看到的棋盘不一致。const BOARD_SIZE 15; const EMPTY 0; const BLACK 1; // players[0] 执黑 const WHITE 2; // players[1] 执白 function handleMove(ws, data) { const room rooms.get(ws.roomId); if (!room) { ws.send(JSON.stringify({ type: error, data: { message: not in room } })); return; } // 1. 对局状态校验 if (room.status ! playing) { ws.send(JSON.stringify({ type: error, data: { message: game not playing } })); return; } // 2. 回合校验 const playerIndex ws.playerIndex; if (room.turn ! playerIndex) { ws.send(JSON.stringify({ type: error, data: { message: not your turn } })); return; } // 3. 落子范围与空位校验 const x Math.floor(data.x); const y Math.floor(data.y); if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) { ws.send(JSON.stringify({ type: error, data: { message: out of range } })); return; } if (room.board[x][y] ! EMPTY) { ws.send(JSON.stringify({ type: error, data: { message: cell occupied } })); return; } // 4. 落子并广播 const piece playerIndex 0 ? BLACK : WHITE; room.board[x][y] piece; room.turn 1 - playerIndex; // 广播落子事件让双方客户端渲染同一颗棋子 broadcastToRoom(room, { type: move, data: { x, y, player: playerIndex, turn: room.turn } }); // 5. 胜负判定 if (checkWin(room.board, x, y, piece)) { room.status over; room.winner playerIndex; broadcastToRoom(room, { type: game_over, data: { winner: playerIndex, board: room.board } }); } }胜负判定checkWin是这个项目的核心算法。五子棋胜负判定只看最后一个落子能否在四个方向横、竖、主对角线、副对角线连成五子function checkWin(board, x, y, piece) { const directions [ [1, 0], // 横向 [0, 1], // 纵向 [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 BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (board[nx][ny] ! piece) break; count; } // 反方向延伸 for (let i 1; i 5; i) { const nx x - dx * i; const ny y - dy * i; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (board[nx][ny] ! piece) break; count; } if (count 5) return true; } return false; }这段算法的逻辑是朝一个方向走到尽头再回头走另一侧把两侧的连续棋子数加起来。只要有一个方向累计达到 5就算赢。参数说明dx、dy的取值决定了四个方向的斜率。横向[1,0]就是沿 x 轴移动纵向[0,1]沿 y 轴移动主对角线[1,1]是左上到右下副对角线[1,-1]是右上到左下。注意循环里先从i1开始因为(x,y)本身已经被计数了。有个实现细节值得注意count 5而不是count 5。因为规则是“连成五子或以上”也算赢有些地方叫长连民间对弈通常默认长连也算胜。如果你要严格执行“够五才赢、长连算赢但下一手才判定”可以自己调整但 5是最通用的做法。broadcastToRoom的实现也很简单function broadcastToRoom(room, message) { const text JSON.stringify(message); for (const player of room.players) { if (player player.readyState 1) { // 1 OPEN player.send(text); } } }注意readyState 1的判断WebSocket 连接可能处于CLOSING或CLOSED状态直接send会抛异常。这个判断是踩过坑后补上的血泪经验。4. 前端源码核心Canvas 棋盘渲染、WebSocket 客户端封装与对局状态机4.1 Canvas 绘制棋盘与落子交互前端的核心是棋盘渲染和 WebSocket 客户端的配合。棋盘用 Canvas 绘制15×15 的网格坐标换算要准确——鼠标点击的像素坐标要转成格子坐标。!-- 前端页面结构HTML 部分 -- canvas idboard width640 height640/canvas div idstatus等待对方加入.../divconst canvas document.getElementById(board); const ctx canvas.getContext(2d); const CELL 40; // 每格 40 像素 const PADDING 20; // 棋盘边缘留白 const BOARD_SIZE 15; // 绘制空棋盘 function drawBoard() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #e8c97a; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #4a3728; // 画网格线 for (let i 0; i BOARD_SIZE; i) { const pos PADDING i * CELL; ctx.beginPath(); ctx.moveTo(PADDING, pos); ctx.lineTo(PADDING CELL * (BOARD_SIZE - 1), pos); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pos, PADDING); ctx.lineTo(pos, PADDING CELL * (BOARD_SIZE - 1)); ctx.stroke(); } }坐标映射函数是关键直接关系到落子准不准function canvasToGrid(offsetX, offsetY) { const x Math.round((offsetX - PADDING) / CELL); const y Math.round((offsetY - PADDING) / CELL); if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) return null; return { x, y }; }这里用Math.round而不是Math.floor这样点击格子边缘时会吸附到最近的格子手感更好。这是从玩家视角出发的细节——五子棋的落子是点击不是拖拽吸附逻辑直接影响误触率。在客户端渲染棋子时先本地画出来再等服务端广播还是等收到广播再画我建议后者。因为这是在线对战必须以服务端广播为准。如果本地先画万一服务端判定非法比如你已经输了还在落子就要回滚棋盘体验更糟。所以正确的流程是点击时发送move消息收到服务端广播的move后才在 Canvas 上画这颗棋子。绘制棋子function drawPiece(x, y, player) { const cx PADDING x * CELL; const cy PADDING y * CELL; ctx.beginPath(); ctx.arc(cx, cy, CELL * 0.4, 0, Math.PI * 2); ctx.fillStyle player 0 ? #1a1a1a : #f5f5f5; ctx.fill(); ctx.strokeStyle #333; ctx.stroke(); }这里player是 0 或 1对应后端消息里的player字段。注意不要用“黑棋/白棋”来命名变量而是用玩家索引——因为在代码层面颜色只是玩家索引的映射。4.2 WebSocket 客户端封装消息分发与自动重连前端不能裸用WebSocket对象要封装成一个客户端类统一处理连接、重连、消息分发。这是整套源码里最值得复用的部分class GameClient { constructor(url) { this.url url; this.ws null; this.reconnectAttempts 0; this.maxReconnect 5; this.handlers {}; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectAttempts 0; this.emit(open); }; this.ws.onmessage (event) { const msg JSON.parse(event.data); this.emit(msg.type, msg.data); }; this.ws.onclose () { this.emit(close); this.scheduleReconnect(); }; this.ws.onerror () { // onerror 之后通常跟随 onclose不需要在这里处理重连 console.error(WebSocket error); }; } // 事件订阅外部通过 on(move, callback) 监听 on(type, handler) { if (!this.handlers[type]) this.handlers[type] []; this.handlers[type].push(handler); } emit(type, data) { const handlers this.handlers[type] || []; for (const handler of handlers) { handler(data); } } send(type, data {}) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type, data })); } } scheduleReconnect() { if (this.reconnectAttempts this.maxReconnect) { console.error(max reconnect attempts reached); return; } // 指数退避1s, 2s, 4s, 8s, 16s const delay Math.pow(2, this.reconnectAttempts) * 1000; setTimeout(() { this.reconnectAttempts; this.connect(); }, delay); } }这个封装解决了三个问题第一消息分发。外部的对局逻辑只需要写client.on(move, handleMove)、client.on(game_over, handleGameOver)不用去解析原始 JSON。第二自动重连。断线后按指数退避算法重连1s → 2s → 4s → 8s → 16s最多尝试 5 次。为什么用指数退避而不是固定间隔因为如果是服务器短暂抖动1 秒后就能恢复如果是服务器挂了几分钟固定间隔会让客户端疯狂握手浪费资源。第三send方法的readyState WebSocket.OPEN判断避免在连接未建立或已断开时调用send抛出InvalidStateError。4.3 对局状态机管理等待、对局中、已结束三个状态五子棋对局的状态不多但直接散落成布尔变量isPlaying、hasWinner会让代码越来越乱。我用状态机来管理const GameState { WAITING: waiting, // 等待对手加入 PLAYING: playing, // 对局中 OVER: over // 已结束 }; class GameScreen { constructor(client) { this.state GameState.WAITING; this.myIndex null; // 当前玩家是 0 还是 1 this.turn 0; // 当前轮到谁 this.board Array(15).fill(null).map(() Array(15).fill(0)); this.client client; this.client.on(joined, (data) this.onJoined(data)); this.client.on(game_start, (data) this.onGameStart(data)); this.client.on(move, (data) this.onMove(data)); this.client.on(game_over, (data) this.onGameOver(data)); this.client.on(error, (data) this.showError(data.message)); } onJoined(data) { this.myIndex data.index; // 自己是第一个玩家时界面显示“等待对方加入” this.renderStatus(等待对方加入...); } onGameStart(data) { // 这里收到 join 消息时已经知道 roomId可以提示对方 this.state GameState.PLAYING; this.myIndex data.players.indexOf(myPlayerId) ! -1 ? data.players.indexOf(myPlayerId) : null; this.turn data.turn; this.renderStatus(this.turn this.myIndex ? 轮到你落子 : 等待对方落子...); } onMove(data) { // 在棋盘上落子 this.board[data.x][data.y] data.player 0 ? 1 : 2; drawPiece(data.x, data.y, data.player); // 更新回合 this.turn data.turn; this.renderStatus(this.turn this.myIndex ? 轮到你落子 : 等待对方落子...); } onGameOver(data) { this.state GameState.OVER; this.renderStatus(data.winner this.myIndex ? 你赢了 : 你输了); } }状态机的核心思想所有状态转换都发生在收到服务端消息后客户端自身不自我推断状态。比如“我点击了一颗棋子”客户端不会立刻认为“该我了”而是等服务端广播move并带上下一个turn才更新 UI。这是在线同步的黄金法则能避免大量因消息延迟导致的状态错乱。5. WebSocket 五子棋的 5 个经典踩坑从消息乱序到连接被静默断开5.1 现象落子消息乱序棋盘状态错乱有人在房间内连续快速落子比如用脚本模拟后发的落子反而先被处理棋盘出现“覆盖”或“跳步”的情况。原因服务端在处理move消息时如果中间插入了异步操作比如写数据库、调用第三方接口Node.js 的事件循环会让多个move交叉执行。即使ws的消息分发本身顺序的但异步回调会让代码交错。解决在handleMessage这一层彻底避免异步。所有落子、校验、广播逻辑全部同步执行。如果你需要记录对局日志用queueMicrotask或者把日志写入批量积压绝不在消息处理链路里await。这是一个设计原则关键对局消息的完整处理必须在同步代码块内完成。实战中我不需要真正的消息队列——单房间对局的并发量很低同步处理足够快。5.2 现象断线重连后双方棋盘不一致玩家 A 断网重连后客户端代码通过new WebSocket(url)重新建立连接但服务端不知道这个新连接对应哪个玩家。如果玩家 A 的 WebSocket 连接对象已经变化ws.roomId无从谈起服务端会把新连接当成“第一次加入”此时玩家 B 还留在房间里等待整个对局卡死。原因断线重连只恢复了传输层连接没有恢复应用层的“身份”。新连接和旧连接是两回事必须主动告知服务端“我是谁我回到哪个房间”。解决重连后发送sync消息带上一开始分配的房间信息和玩家身份标识。// 客户端重连成功后 client.on(open, () { if (myRoomId myPlayerId) { client.send(sync, { roomId: myRoomId, playerId: myPlayerId }); } }); // 服务端 function handleSync(ws, data) { const room rooms.get(data.roomId); if (!room) return; const player room.players.find(p p p.playerId data.playerId); if (!player) { ws.send(JSON.stringify({ type: error, data: { message: player not found } })); return; } // 用新连接替换旧连接 player.ws ws; ws.roomId room.id; ws.playerIndex player.playerIndex; // 推送全量棋盘和状态 ws.send(JSON.stringify({ type: sync, data: { roomId: room.id, board: room.board, turn: room.turn, status: room.status, yourIndex: player.playerIndex } })); }这里我建议把playerId做进连接消息里而不是依赖ws对象上的字段——因为重连后ws对象是全新的只有playerId是能跨连接识别的稳定身份。5.3 现象连接空闲一会儿就被断开有玩家挂着页面不动既不走棋也不刷新过几分钟发现连接已断开重新进入房间时要重建对局。原因浏览器或中间代理如 Nginx、云厂商的 LB对空闲 TCP 连接有超时回收策略常见的有 60 秒、120 秒。WebSocket 连接如果不主动发数据就会被系统判定为空闲而回收。解决应用层心跳。浏览器端的 WebSocket API 没有原生 ping 方法ws.ping()只在 Node.js 等非浏览器环境有所以只能靠发送真实的消息帧来做心跳。常见做法是每 30 秒发送一条heartbeat消息服务端收到后原样返回{type:heartbeat, ok:true}。客户端如果连续 3 次心跳没有响应就主动关闭连接并触发重连逻辑。// 客户端心跳 setInterval(() { if (client.ws client.ws.readyState WebSocket.OPEN) { client.send(heartbeat); missedHeartbeats; if (missedHeartbeats 3) { client.ws.close(); // 会触发 onclose - scheduleReconnect } } }, 30000); // 服务端收到 heartbeat 时 function handleHeartbeat(ws) { ws.send(JSON.stringify({ type: heartbeat, data: { ok: true } })); }这里有个细节不要用setInterval直接每 30 秒发而应该在收到上一次心跳响应后再安排下一次。否则网络抖动时心跳会重叠造成虚假的“心跳丢失”判断。我写过一个改进版setTimeout递归调用每次收到响应后重新计时。5.4 现象Nginx 代理后 WebSocket 握手失败部署到服务器后客户端连接wss://domain/game握手一直失败但直接连内网 IP 却能通。原因Nginx 默认并不识别 WebSocket 握手请求的Upgrade头它把请求当作普通 HTTP 转发导致后端认为没有升级协议返回 400 或直接断开。解决Nginx 配置必须显式声明 WebSocket 升级头并且关闭缓冲location /game/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection Upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; }三个关键参数一是Upgrade和Connection头必须传透这是 WebSocket 协议升级的基础二是proxy_read_timeout和proxy_send_timeout设置 3600 秒避免 Nginx 在长连接空闲时主动断开三是proxy_buffering off关闭缓冲否则消息可能被 Nginx 攒批发送影响实时性。5.5 现象胜负判定漏判或误判连成五子却没有触发game_over或者棋盘边缘区域出现误判。原因checkWin里的边界处理没做好。比如在 x0 的位置落子朝dx-1方向遍历时直接访问了board[-1][y]得到undefined与piece比较后被认为不相等而跳出循环看似没问题但如果undefined恰好被隐式转换和piece比较就可能在边缘产生误判。另一个常见坑是只检查了“最后一手”的四个方向没有检查整盘——这其实是正确的因为五子棋只要出现连五就结束不需要全盘扫描。解决每次扩展前显式做边界检查只检查四个方向横、竖、两条对角线不要多检查其他方向。这段逻辑我已经在上文代码里写好了——注意先if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break;再做取值比较顺序不能反。边界判断还有一个容易忽略的点当dx0, dy±1时x 方向的边界检查仍然要做否则board[x][ny]中 ny 越界会被undefined干扰。正确做法是把四个方向的检查统一写在一个 helper 里而不是每个方向分开判断。6. 进阶心跳机制的完整实现与并发压测验证系统到底能不能撑住真实对局6.1 服务端主动 ping 与客户端心跳组合一套完整的“WebSocket 心跳机制实现”前面避坑章节提到的心跳是一个简化版本真正生产环境建议“双轨心跳”客户端每 30 秒发一条应用层heartbeat服务端同时用ws库的原生ping帧做传输层探活。这两者分工不同应用层心跳确认“业务还在跑”传输层 ping 确认“TCP 链路还通着”。比如路由器的 NAT 表把空闲映射条目清掉了TCP 层可能还没发现但业务层心跳已经卡住。// 服务端心跳每 30 秒 ping 一次10 秒内没收到 pong 就断开 const HEARTBEAT_INTERVAL 30000; const HEARTBEAT_TIMEOUT 10000; const heartbeatTimer setInterval(() { for (const ws of wss.clients) { if (ws.isAlive false) { ws.terminate(); // 强制关闭失活连接 continue; } ws.isAlive false; ws.ping(); } }, HEARTBEAT_INTERVAL); // connection 事件里 ws.isAlive true; ws.on(pong, () { ws.isAlive true; });为什么ws.isAlive先置false再ping这是锁定状态的常用技巧30 秒前设置为 false只有 pong 回调能把它重新置 true。当下一个 30 秒周期来临时如果还是 false说明上一次 ping 没有得到 pong连接很可能已经死了此时直接terminate()清理掉避免死连接占用资源。terminate()是立即断开不做四次挥手适合这种确认失活的场景。6.2 状态快照同步重连后悔棋药的正确姿势sync消息推送的board是全量 15×15 二维数组JSON 序列化后大约 900 字节。这个体量完全值得全量推送不要做增量同步——省下来几百字节不值得引入复杂的协议复杂度。一个更稳妥的做法是给每次落子加一个单调递增的seq序号客户端本地也保存最后收到的seq。重连后sync消息带上lastSeq客户端对比如果发现本地落后了 N 步可以再请求sync_after补差异。但说实话对五子棋这种低频游戏一整局最多 225 手全量同步已经足够增量同步是过度设计。我做过一个实时文档协作项目把五子棋的 sync 设计也带过去了结果发现棋盘本来就小全量同步永远快过增量补缺。服务端保留房间状态的时间窗口设为 5 分钟。超出这个窗口房间销毁玩家重连回来后只能新建房间。时间窗口的取舍太短则玩家重连来不及太长则服务端内存被空闲房间占满。500 个同时在线房间、每个房间保存一个 15×15 数组内存开销不到 1MB压力不大所以窗口可以放宽。6.3 并发压测用 Node 脚本模拟 200 场对局检验稳定性写完源码后不要只靠浏览器手动测试。我一般写一个压测脚本模拟多个客户端并发建立连接、加入房间、轮流落子直到终局统计关键指标。// 压测脚本模拟并发对局 const WebSocket require(ws); async function runSingleGame(gameId) { const ws1 new WebSocket(ws://localhost:8080/game); const ws2 new WebSocket(ws://localhost:8080/game); await Promise.all([ new Promise(res ws1.on(open, res)), new Promise(res ws2.on(open, res)) ]); // ws1 建房ws2 加入 ws1.send(JSON.stringify({ type: join, data: {} })); const joinMsg await waitMessage(ws1, joined); const roomId joinMsg.data.roomId; ws2.send(JSON.stringify({ type: join, data: { roomId } })); // 等待对局开始 await waitMessage(ws2, game_start); // 双方轮流落子按固定模式走到终局 let turn Math.random() 0.5 ? 0 : 1; const moves generateTestMoves(); // 预生成一串合法落子 for (let i 0; i moves.length; i) { const currentPlayer (i % 2) 0 ? (turn 0 ? ws1 : ws2) : (turn 0 ? ws2 : ws1); const move moves[i]; currentPlayer.send(JSON.stringify({ type: move, data: move })); await waitMessage(ws1, move); await waitMessage(ws2, move); } ws1.close(); ws2.close(); } async function testConcurrentGames(count) { const start Date.now(); const games []; for (let i 0; i count; i) { games.push(runSingleGame(i)); } await Promise.all(games); console.log(完成 ${count} 场对局总耗时 ${Date.now() - start}ms); } testConcurrentGames(200);压测时重点看三个指标连接建立是否全部成功有没有超过maxReconnect的情况、所有move是否都能在两个客户端都收到有没有丢消息、对局是否都能正确结束。我实际压测时发现过一个隐蔽问题当 200 个连接同时建立时Node.js 默认的maxHeaderSize可能不够用每个 WebSocket 握手包含 HTTP 头导致部分连接被拒绝。如果你部署时不打算调系统参数可以在压测前先把并发数分成 50 批做。真机部署时再把连接数上调到几百就够用了五子棋这个场景不会出现万人同时在线的规模。6.4 验证的最后一环本地起服务用浏览器开两个标签页实测脚本跑通后别急着上生产。打开两个浏览器窗口一个普通窗口、一个隐身窗口避免共享会话一个建房一个加入手动走完一整局重点验证四个场景正常轮流落子、中途刷新页面重连、断网恢复浏览器 DevTools 的 Network 面板里切 Offline 再切回来、极端情况下一方连赢五子。这套方案做到最后我最大的体会是WebSocket 战对系统的代码量不算大难的是把“连接管理”这件事想清楚。心跳、重连、状态同步、消息顺序这些不是功能逻辑而是保障功能逻辑能稳定跑完的基础设施。就像五子棋没什么高端规则但一盘棋能不能顺利下完靠的是棋盘和棋子之间的摩擦力是否足够稳定。希望这篇笔记能帮你把 WebSocket 五子棋从概念拆到可运行少踩我说的那些坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑