WebSocket实战:在线五子棋对战与实时状态同步
发布时间:2026/9/28 13:20:33来源:尧图网络
简介这是一套面向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 而不是轮询和 SSE2.1 三种实时方案在五子棋场景下的真实差距先把选型讲清楚不然后面写代码都是空中楼阁。实时对战常见三条路短轮询、SSEServer-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 五子棋的骨架不大但把通信、状态、重连这三件事做扎实换成象棋、围棋、你画我猜都是同一套思路。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网