简介:这是一套面向C++后端与Web开发学习者的在线五子棋对战游戏完整源码,适合想通过实战理解网络编程与前后端协作的开发者练手。项目以WebSocket为核心通信方案,实现了用户注册登录、对战匹配、实时落子对战与实时聊天等完整业务闭环,覆盖从会话管理、匹配调度到房间逻辑的服务端设计思路。压缩包共33个文件,约6.13MB,以13个hpp头文件承载服务端核心模块,配合4个HTML页面与4个CSS样式构建前端界面,另有JavaScript脚本、SQL建表文件、Makefile及JSON配置等辅助内容,目录按服务端、网页资源与工具模块分层组织,结构清晰便于按需阅读。目前已有406人学习下载,读者可从中获取一套可直接运行的赛题级项目方案,理解WebSocket长连接、会话与房间管理、匹配队列等关键实现,并借鉴其模块划分与排错思路,用于课程设计或自主进阶练习。
1. 从一张棋盘说起:WebSocket 五子棋对战到底在解决什么问题
两个人隔着几百公里下五子棋,最朴素的做法是轮询——客户端每隔一秒问服务器「对方落子了吗」。这个方案能跑,但体验很差:落子延迟肉眼可见,服务器被大量空请求打满,棋局状态还容易因为请求乱序而错乱。WebSocket 的价值就在这里:它把「客户端反复问」变成「服务器主动推」,一条长连接建立后,双方任意时刻都能往对方推消息,落子几乎实时到达,服务器只处理真正发生的事件。
这个标题讲的是用 WebSocket 做一套在线五子棋对战游戏,并配套可运行的源码。它要解决的核心问题有三个:实时同步双方落子、维护一份权威的棋局状态、处理断线重连和房间匹配。适合谁?适合想学 WebSocket 实战的前后端开发者、要做课程设计的学生、以及想找一个「小但完整」的实时对战项目练手的人。五子棋规则简单,正好把注意力留给通信和状态同步这两件真正难的事。
2. 通信层选型:为什么是 WebSocket 而不是轮询和 SSE
2.1 三种实时方案在五子棋场景下的真实差距
先把选型讲清楚,不然后面写代码都是空中楼阁。实时对战常见三条路:短轮询、SSE(Server-Sent Events)、WebSocket。
短轮询是客户端定时发 HTTP 请求问服务器有没有新消息。实现最简单,但延迟等于轮询间隔,间隔调小服务器压力陡增,调大体验又差。五子棋每一步都要即时反馈,轮询基本出局。
SSE 是服务器单向推送给客户端,基于 HTTP,浏览器原生支持 EventSource。它适合「后台有数据、前端只接收」的推送场景,比如进度条、通知流。但五子棋是双向的——你也要把落子发给服务器。SSE 只能服务器推,客户端发消息还得另开 HTTP 接口,等于两条通道,状态对齐更麻烦。
WebSocket 是全双工,一条连接双向收发,握手用 HTTP 升级协议,之后就是帧通信。落子、悔棋、认输、聊天都能走同一条连接。代价是要自己处理心跳、重连、消息格式。对五子棋这种「低频但要求实时」的场景,WebSocket 是性价比最高的选择。
| 方案 | 方向 | 延迟 | 服务器压力 | 五子棋适配度 |
|---|---|---|---|---|
| 短轮询 | 客户端拉 | 高(等于间隔) | 高(大量空请求) | 差 |
| SSE | 服务器单向推 | 低 | 中 | 中(需另开上行通道) |
| WebSocket | 全双工 | 低 | 低(仅事件触发) | 高 |
2.2 消息协议设计:一条消息该长什么样
选完通信方式,紧接着要定消息格式。很多人一上来就socket.send("落子"),后面加功能时全乱套。正确做法是先定协议,用 JSON 承载,字段固定。
我一般会设计成这样的结构:
{ "type": "move", "roomId": "r1001", "playerId": "p_abc", "payload": { "x": 7, "y": 7 }, "ts": 1710000000000 }type是消息类型,决定服务端怎么处理;roomId定位房间;playerId标识是谁发的,服务端必须校验这个 id 和连接绑定关系,不能信客户端自称;payload放具体数据;ts是时间戳,用于排查乱序和延迟。
常见的type至少要有这几类:join(加入房间)、move(落子)、undo(悔棋请求)、undo_ack(悔棋应答)、resign(认输)、sync(全量状态同步,重连时用)、ping/pong(心跳)、error(错误回执)。
提示:协议一旦定下来就写进文档,前后端各留一份常量定义,别让字符串散落在代码各处。改协议时两边一起改,否则就是经典的「连接上了但收不到消息」。
2.3 服务端最小可跑实现
下面给一个基于 Node.js 和ws库的最小服务端,能跑通「两人进同一房间、互相看到落子」。这是最常见、最可靠的起步方案。
// server.js const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); const rooms = new Map(); // roomId -> { players: [], board: 15x15 } function getRoom(roomId) { if (!rooms.has(roomId)) { rooms.set(roomId, { players: [], board: Array.from({ length: 15 }, () => Array(15).fill(0)), turn: 1, // 1 黑 2 白 }); } return rooms.get(roomId); } wss.on('connection', (ws) => { ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch (e) { return ws.send(JSON.stringify({ type: 'error', reason: 'bad_json' })); } const room = getRoom(msg.roomId); if (msg.type === 'join') { if (room.players.length >= 2) { return ws.send(JSON.stringify({ type: 'error', reason: 'room_full' })); } ws.playerId = msg.playerId; ws.roomId = msg.roomId; room.players.push(ws); // 广播当前人数,让双方知道可以开始了 broadcast(room, { type: 'room_state', count: room.players.length }); } if (msg.type === 'move') { const { x, y } = msg.payload; // 服务端权威校验:轮次、越界、重复落子 if (room.board[y][x] !== 0) { return ws.send(JSON.stringify({ type: 'error', reason: 'occupied' })); } room.board[y][x] = room.turn; room.turn = room.turn === 1 ? 2 : 1; broadcast(room, { type: 'move', payload: { x, y }, ts: Date.now() }); } }); ws.on('close', () => { const room = rooms.get(ws.roomId); if (room) { room.players = room.players.filter((p) => p !== ws); broadcast(room, { type: 'room_state', count: room.players.length }); } }); }); function broadcast(room, data) { const str = JSON.stringify(data); room.players.forEach((p) => p.readyState === 1 && p.send(str)); }逻辑说明:rooms用 Map 保存所有房间,每个房间维护棋盘二维数组和当前轮次。join时限制最多两人,并把人数广播出去。move时服务端先校验目标格是否为空,再落子、切换轮次、广播。关键点是服务端是唯一权威,客户端传来的坐标只是「请求」,能不能落由服务端说了算。
参数说明:棋盘 15×15 是标准五子棋尺寸,board[y][x]用 0/1/2 表示空/黑/白。readyState === 1是 WebSocket 的 OPEN 状态,广播前必须判断,否则给已关闭的连接发消息会抛错。端口 8080 可改,生产环境要走反向代理并配 TLS。
2.4 客户端连接与落子渲染
客户端用原生 WebSocket API 即可,不需要额外库。
// client.js const ws = new WebSocket('ws://localhost:8080'); const myId = 'p_' + Math.random().toString(36).slice(2, 8); ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', roomId: 'r1001', playerId: myId })); }; ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.type === 'move') { drawStone(msg.payload.x, msg.payload.y); // 渲染对方或自己的落子 } if (msg.type === 'room_state') { updateStatus(`房间人数:${msg.count}`); } if (msg.type === 'error') { console.warn('服务端拒绝:', msg.reason); } }; function sendMove(x, y) { ws.send(JSON.stringify({ type: 'move', roomId: 'r1001', playerId: myId, payload: { x, y }, })); }逻辑说明:连接建立后立刻发join。收到move就渲染棋子,注意不要在自己点击时直接画,而是等服务端广播回来再画,这样双方状态才一致。sendMove只负责发请求,不负责改本地状态。
参数说明:myId这里用随机串模拟,真实项目应由登录态下发,服务端要校验这个 id 与连接的绑定,防止冒名落子。roomId实际应由匹配系统分配,这里写死方便调试。
3. 棋局状态与胜负判定:服务端权威怎么落地
3.1 为什么胜负必须在服务端算
新手最容易犯的错,是在客户端点完第五颗子就本地判赢,然后弹窗。问题是:网络延迟下双方看到的棋盘可能短暂不一致,客户端判赢会出现「我这边赢了、对方那边还没收到最后一手」的尴尬。更严重的是,客户端可以被篡改,改个坐标就能伪造胜利。
正确做法是:落子、判胜、切换轮次全部在服务端完成,客户端只负责渲染服务端广播的结果。服务端判赢后广播game_over,带上赢家和获胜连线,双方各自渲染。
3.2 五子连珠判定的实现
判定逻辑不复杂,但边界要写对。以刚落下的点为中心,向四个方向(横、竖、两条斜线)各数同色连续棋子,任一方向达到 5 即胜。
// 判断 (x, y) 落子后是否五连 function checkWin(board, x, y, color) { const dirs = [[1, 0], [0, 1], [1, 1], [1, -1]]; for (const [dx, dy] of dirs) { let count = 1; // 正方向延伸 for (let i = 1; i < 5; i++) { const nx = x + dx * i, ny = y + dy * i; if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15) break; if (board[ny][nx] !== color) break; count++; } // 反方向延伸 for (let i = 1; i < 5; i++) { const nx = x - dx * i, ny = y - dy * i; if (nx < 0 || ny < 0 || nx >= 15 || ny >= 15) break; if (board[ny][nx] !== color) break; count++; } if (count >= 5) return true; } return false; }逻辑说明:四个方向向量覆盖横竖两斜。每个方向从落子点向正负两侧各延伸最多 4 格,累计同色数量,达到 5 就返回 true。注意count初始为 1,因为落子点本身算一颗。
参数说明:dirs里的[1, -1]是反对角线方向,别写成[-1, -1]重复了另一条斜线。边界判断nx/ny必须在 0 到 14 之间,越界立即 break,否则会读到undefined导致误判。
注意:五子棋有「禁手」规则(黑棋三三、四四、长连禁手),如果要做正规对局,判定要更复杂。课程设计和休闲对战一般不做禁手,实现前先和需求方确认,别自己加戏。
3.3 断线重连与全量同步
长连接一定会断——切后台、网络抖动、服务器重启。断了之后客户端重连,本地棋盘可能已经过期,必须让服务端推一份全量状态。
// 服务端:收到 sync 请求时回全量 if (msg.type === 'sync') { ws.send(JSON.stringify({ type: 'sync', payload: { board: room.board, turn: room.turn }, })); }// 客户端:重连后主动请求同步 ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', roomId: 'r1001', playerId: myId })); ws.send(JSON.stringify({ type: 'sync', roomId: 'r1001', playerId: myId })); };逻辑说明:重连后先join恢复房间成员身份,再sync拉全量棋盘。客户端收到sync后用服务端棋盘覆盖本地,避免「我这边多一颗子」的错位。
参数说明:全量同步的数据量很小(15×15 数组),直接传没问题。如果房间多、棋盘大,可以只传增量,但五子棋没必要优化到这个程度。
3.4 心跳机制:别让连接悄悄死掉
WebSocket 连接可能因为中间设备超时被静默断开,客户端却以为还连着。心跳就是定期发一个ping,服务端回pong,超时没回就重连。
// 客户端心跳 let heartbeatTimer; function startHeartbeat() { heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, 30000); // 30 秒一次 } ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.type === 'pong') { lastPong = Date.now(); // 记录,用于判断是否超时 } };逻辑说明:每 30 秒发一次ping,服务端收到回pong。客户端记录最后一次pong时间,如果超过 60 秒没收到,就主动关闭并重连。
参数说明:30 秒是常见值,太短浪费流量,太长断线发现慢。服务端也要有对应逻辑:收到ping回pong,同时可以定期清理长时间没消息的连接。
4. 避坑与排查:那些让连接「连上了却没反应」的坑
4.1 连接成功但收不到消息
现象:客户端onopen触发了,但onmessage一直不响。
原因:最常见是服务端广播时没判断readyState,给一个还没完全就绪或已关闭的连接发消息抛了异常,导致后续广播中断;其次是消息格式不对,客户端JSON.parse抛错被吞掉。
解决:广播前统一判断p.readyState === 1;客户端onmessage里对JSON.parse做 try/catch 并打印原始数据,先确认到底有没有收到。
4.2 双方棋盘不一致
现象:一方显示五颗子,另一方只显示四颗。
原因:客户端在自己点击时直接画了棋子,同时又等服务端广播,导致本地多画或顺序错乱;或者服务端广播时漏发了某个玩家。
解决:坚持「本地不抢先渲染」,所有落子都等服务端广播回来再画。服务端广播用统一的broadcast函数,别在分支里手动遍历。
4.3 心跳把连接「打死」
现象:加了心跳后,连接反而频繁断开。
原因:客户端发ping但服务端没实现pong回复,客户端等不到pong判定超时,主动断开重连,形成循环。
解决:心跳是双向约定,服务端必须实现ping收、pong回。上线前用websocket test client类工具手动发一条ping,确认能收到pong再放行。
4.4 房间满了还在等
现象:第三个人进房间,界面卡在「等待对手」,没有任何提示。
原因:服务端join时判断了room_full并发了error,但客户端没处理error类型消息。
解决:客户端必须处理error,把reason映射成用户能看懂的提示,比如room_full显示「房间已满,请换一个」。别让错误静默。
4.5 重连后重复加入房间
现象:断线重连几次后,房间人数显示 4、6、8,越加越多。
原因:close事件里没把断开的连接从players移除,或者重连时旧连接还没触发close,新连接又join了一次。
解决:close里务必从players过滤掉当前连接;join时按playerId去重,同一个玩家只保留最新连接。
5. 进阶技巧:把「能跑」变成「敢用」
前面四章把最小可跑的对战跑通了,这一章讲几个让项目从「能演示」变成「敢给人用」的技巧,都是我自己踩过之后固定下来的习惯。
第一个是落子幂等。网络重发、用户手抖双击,都可能让同一手棋发两次。服务端在move处理里加一个「该玩家本回合是否已落子」的判断,或者给每条消息带一个自增序号,服务端记录已处理的最大序号,重复的直接丢弃。这一步能省掉大量「棋盘上莫名多一颗子」的排查。
第二个是悔棋的协商流程。悔棋不能单方面改棋盘,否则双方状态又错位。正确流程是:请求方发undo,服务端转发给对手,对手回undo_ack(同意/拒绝),服务端收到同意后回退两步(双方各一手),再广播新的棋盘状态。整个过程服务端只做转发和状态变更,不替玩家做决定。
第三个是用状态机管房间。房间至少有waiting(等人)、playing(对局中)、over(结束)三个状态。move只在playing状态处理,join只在waiting或有人掉线时允许。把状态判断集中在一处,比散落各处的 if 好维护得多。
| 技巧 | 解决的问题 | 落地位置 |
|---|---|---|
| 落子幂等 | 重复落子、双击 | 服务端 move 处理 |
| 悔棋协商 | 单方改状态导致错位 | undo / undo_ack 消息对 |
| 房间状态机 | 非法操作、状态混乱 | 房间对象 + 统一判断 |
第四个是日志要能复盘。每次落子、每次状态变更都打一条结构化日志,带上roomId、playerId、type、ts。出问题时按roomId一筛,整局棋的来龙去脉清清楚楚。我吃过没有日志的亏——线上有人说「我明明赢了却判输」,翻遍代码找不到原因,后来加上日志才发现是重连时全量同步覆盖了本地未提交的落子。这种问题没有日志就是黑匣子。
最后一个习惯:上线前用两个浏览器窗口手动对打十局,故意制造断网、刷新、双击、同时落子。自动化测试覆盖不到这些交互边界,手动对打十分钟,比写一天单测更能暴露真问题。这套 WebSocket 五子棋的骨架不大,但把通信、状态、重连这三件事做扎实,换成象棋、围棋、你画我猜都是同一套思路。希望帮到你。
本文还有配套的精品资源,点击获取