news 2026/10/24 10:12:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WebSocket五子棋对战游戏开发:协议、心跳与重连实战

简介:基于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 头,空闲时也在空转只能客户端主动问全支持
SSE(Server-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]是右上到左下。注意循环里先从i=1开始,因为(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 id="board" width="640" height="640"></canvas> <div id="status">等待对方加入...</div>
const 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里的边界处理没做好。比如在 x=0 的位置落子,朝dx=-1方向遍历时直接访问了board[-1][y],得到undefined,与piece比较后被认为不相等而跳出循环,看似没问题,但如果undefined恰好被隐式转换和piece比较,就可能在边缘产生误判。另一个常见坑是只检查了“最后一手”的四个方向,没有检查整盘——这其实是正确的,因为五子棋只要出现连五就结束,不需要全盘扫描。

解决:每次扩展前显式做边界检查,只检查四个方向(横、竖、两条对角线),不要多检查其他方向。这段逻辑我已经在上文代码里写好了——注意先if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) break;再做取值比较,顺序不能反。

边界判断还有一个容易忽略的点:当dx=0, 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 五子棋从概念拆到可运行,少踩我说的那些坑。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 1:02:52

AI智能体开发平台落地实战:选型、架构与避坑指南

简介&#xff1a;一份聚焦大模型应用与AI智能体开发平台的PPT&#xff0c;适合AI开发者、产品经理及高校师生入门智能体开发。内容以女娲智能体平台为主线&#xff0c;系统讲解插件、工作流、触发器如何拓展智能体能力&#xff0c;文档/表格/照片三类知识库的应用场景&#xff…

作者头像 李华
网站建设 2026/10/23 12:41:29

Ubuntu远程桌面下Firefox打不开闪退?完整排查指南

如果你用VNC或xrdp远程登录Ubuntu&#xff0c;点了Firefox图标后界面闪一下就没了&#xff0c;或者进程起来一会儿又自己退出&#xff0c;别急着卸载重装。这类问题在远程桌面场景下非常典型&#xff0c;根因通常不在Firefox本身&#xff0c;而在会话环境。我前前后后接手过不下…

作者头像 李华
网站建设 2026/10/17 16:49:26

VS Code中Claude Code命令行界面详解:安装配置与实战排查

1. 先别慌&#xff0c;看到命令行是正常现象 很多朋友在 VS Code 里安装 Claude Code 之后&#xff0c;第一次点击打开&#xff0c;发现弹出的不是想象中的聊天窗口、侧边栏对话面板&#xff0c;而是一个黑底白字的终端界面&#xff0c;心里多少会咯噔一下&#xff1a;“是不是…

作者头像 李华
网站建设 2026/10/17 19:33:21

Logitech Options 8.34.91 Mac深度适配指南

简介&#xff1a;本资源为Mac平台专用的罗技鼠标高级管理工具Logi Options 8.34.91正式安装包&#xff0c;面向Mac用户&#xff08;尤其设计师、程序员及多任务办公人群&#xff09;&#xff0c;解决原生系统对罗技鼠标功能支持有限、按键无法自定义、DPI与手势操作缺失等核心痛…

作者头像 李华
网站建设 2026/10/19 16:50:59

美术也能学会UE4蓝图:从交互装置到材质光照的落地路径

简介&#xff1a;《UE4艺术大师蓝图全套》是一套面向游戏开发爱好者和初级、进阶开发者的综合教程资源&#xff0c;聚焦UE4蓝图可视化编程与艺术设计实战。内容覆盖基础项目配置、场景搭建、角色动作、物理模拟、光照渲染、AI行为树及网络同步等模块&#xff0c;适合希望以非代…

作者头像 李华
网站建设 2026/10/16 6:24:03

Java大鱼吃小鱼源码实战:Swing桌面游戏开发与避坑指南

简介&#xff1a;这是一份基于Java Swing实现的「大鱼吃小鱼」游戏完整源码包&#xff0c;面向Java初学者与游戏开发入门者&#xff0c;帮助读者通过一个可运行的小项目理解GUI编程、事件监听与动画渲染的完整流程。压缩包共1171个文件&#xff0c;约61.41MB&#xff0c;以715张…

作者头像 李华