
简介基于WebSocket的在线五子棋对战游戏设计源码面向具备一定Web基础、希望深入理解实时通信机制的学习者。项目完整实现了用户注册登录、对战匹配、实时对局与实时聊天四大核心功能可帮助读者掌握C后端与HTML/CSS/JavaScript前端联调、WebSocket长连接交互及数据库存储等关键技术。资源包共含33个文件以13个hpp头文件承载后端模块声明辅以4个CSS、4个HTML和对应JS文件构建前端界面另提供SQL脚本、makefile及JSON配置等整体约6.13MB结构清晰便于按模块阅读。已有406人浏览学习适合作为课程设计或个人练手项目通过研读源码可同时提升服务端设计与前端交互能力并可直接运行体验完整对战流程。1. 用 C 手写 WebSocket 五子棋这份源码把在线对弈的整条链路摊开了最近帮朋友拆一个 C 写的联机游戏项目第一反应是“怎么还有人用 C 写 WebSocket 服务端”。但把这份基于 WebSocket 的在线五子棋对战源码完整过了一遍之后我发现它的价值恰恰在这它没用现成的网络库而是从握手、帧解析、状态管理到匹配建房全手写把 WebSocket 在线对弈的整条链路摊开在你面前。项目包含 33 个文件注册登录、对战匹配、实时对战、实时聊天都有前后端代码齐全。适合两类人想弄懂 WebSocket 协议底层细节的 C 学习者以及需要一个完整可跑项目的课程设计备选。接下来我按“协议实现 → 核心流程 → 前端对接 → 踩坑记录 → 编译验证”的顺序把它拆透。2. 服务端结构与协议实现从 server.hpp 到一帧数据的完整旅程2.1 模块地图这份源码里每个 hpp 负责什么先看源码根目录的顶层结构。项目把服务端全部放在 source 目录下服务端核心是一组 hpp 头文件加一个 gobang.cc 入口前端页面放在 wwwroot 里数据库脚本是 db.sql。第一次打开的人容易迷失在十几个 hpp 里我建议按照“网络 → 会话 → 业务”三层去读先列一张模块职责表文件职责对应层次server.hpp监听端口、事件循环、新连接接入网络层session.hpp单个 WebSocket 连接的状态与消息收包连接层matcher.hpp对战匹配队列把两名玩家撮合进同一房间业务层room.hpp房间管理持有对局双方会话与棋盘状态业务层db.hpp / mysql_util.hppMySQL 访问封装注册登录查库数据层logger.hpp日志输出调试握手和帧问题时最有用基础设施util/string_util.hpp字符串切分、字节工具基础设施util/json_util.hppJSON 序列化与解析基础设施gobang.cc主程序装配以上模块并启动服务入口推荐阅读顺序是 server.hpp → session.hpp → room.hpp → gobang.cc。server.hpp 负责 accept 新 socketsession.hpp 负责把 socket 字节流解析成完整的 WebSocket 帧room.hpp 在业务层处理落子和胜负。你读的时候会发现每个头文件都尽量保持独立编译单元之间靠引用关系串联这一点对二开非常友好想换匹配策略只需要改 matcher.hpp不想动网络层。2.2 握手与帧解析WebSocket 协议在 C 里怎么落地WebSocket 和普通 TCP 最大的区别是连接建立时要先完成一次 HTTP 升级握手之后所有数据都要按帧格式切包。很多人在这一步就卡住因为服务端收到的第一段数据是普通 HTTP 文本之后才是二进制帧混在一起处理很容易翻车。源码里 session.hpp 的核心做法是先判断握手是否完成没完成就走 HTTP 解析分支完成后才进入帧解析分支。// session.hpp 中握手校验的核心逻辑 static const std::string WS_GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; std::string compute_websocket_accept(const std::string key) { // 1. 客户端 key 拼上固定 GUID std::string to_hash key WS_GUID; // 2. 做 SHA-1 哈希并做 Base64 编码 std::string sha1_hex sha1_hex_digest(to_hash); return base64_encode(hex_to_bin(sha1_hex)); } bool Session::handle_handshake(const std::string header) { // 从 HTTP 请求头里取 Sec-WebSocket-Key 字段 std::string client_key extract_header_value(header, Sec-WebSocket-Key); if (client_key.empty()) return false; std::string accept compute_websocket_accept(client_key); std::string response HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Accept: accept \r\n\r\n; send_raw(response); handshake_done_ true; return true; }这段代码里最容易被忽略的是compute_websocket_accept的运算顺序先 SHA-1再 Base64顺序反了服务端返回的 Sec-WebSocket-Accept 就是错的浏览器会直接报Error during WebSocket handshake。GUID 是协议写死的常量不能改也不能换成自己的字符串这是 RFC 6455 的强制要求。握手完成之后客户端发来的每一帧都需要解析。帧格式是固定的第一字节的高 1 位是 FIN低 4 位是 opcode第二字节的高 1 位是 MASK 标志低 7 位是 payload 长度。客户端向服务端发消息必须带掩码这是协议强制要求服务端给客户端回包则不能带掩码这个方向性很多人搞反。// session.hpp 中帧解析的简化实现 bool Session::parse_frame(const uint8_t* buf, size_t len) { if (len 2) return false; bool fin (buf[0] 0x80) ! 0; uint8_t opcode buf[0] 0x0F; bool masked (buf[1] 0x80) ! 0; uint64_t payload_len buf[1] 0x7F; size_t offset 2; if (payload_len 126) { // 16 位扩展长度 payload_len (buf[2] 8) | buf[3]; offset 2; } else if (payload_len 127) { // 64 位扩展长度一般用不到但协议要求实现 payload_len 0; for (int i 0; i 8; i) { payload_len (payload_len 8) | buf[offset i]; } offset 8; } // 解掩码每 4 字节循环异或 uint8_t mask_key[4] {0}; if (masked) { memcpy(mask_key, buf offset, 4); offset 4; } std::string payload(buf offset, payload_len); for (uint64_t i 0; i payload_len; i) { payload[i] ^ mask_key[i % 4]; } if (opcode 0x1) { handle_text_message(payload); // 文本消息 } else if (opcode 0x8) { close_connection(); // 关闭帧 } else if (opcode 0x9) { send_pong(payload); // ping 帧回 pong } return true; }帧解析最容易出 bug 的地方是长度字段的偏移量计算当 payload_len 是 126 时后面跟 2 字节扩展长度掩码 key 从 offset 4 开始当 payload_len 是 127 时后面跟 8 字节扩展长度。如果你只处理了 2 字节扩展长度客户端发一个大消息过来就会把掩码 key 的位置算错解出来的内容全是乱的。源码里对 127 分支的处理很完整这一块值得仔细读。2.3 为什么服务端选 C性能、状态管理与学习价值说实话做在线五子棋这种低并发项目Python 的 aiohttp 或 Node.js 的 ws 库都能轻松搞定代码量还少一半。这份源码坚持用 C 手写我觉得核心原因有三点第一是学习价值手写一次握手和帧解析WebSocket 协议就不再是黑匣子后面看任何语言的 ws 库源码都能一眼看懂第二是状态管理C 的结构体加智能指针天然适合表达 session、room 这种强关联对象第三是部署自由度编译出来就是一个二进制不依赖运行时环境。代价是开发效率低调试成本高所以源码里专门写了 logger.hpp日志输出贯穿整个会话生命周期运行服务时把日志级别调到 DEBUG每个连接建立、每帧收发、每次匹配成功都有记录。3. 核心流程落地注册登录、匹配建房、落子判胜负的实现细节3.1 注册登录MySQL 访问层与 Session 生命周期源码里 db.sql 建了两张核心表users 表存账号密码对局记录相关表存棋谱。注册和登录都走 MySQLdb.hpp 和 mysql_util.hpp 封装了连接池和增删改查。连接池是这类 C 服务端项目最容易偷懒的地方但这源码里做了基本实现拿连接前先 ping 一下断掉就重连避免 MySQL 的 wait_timeout 把空闲连接回收掉之后程序还在用陈旧连接。// mysql_util.hpp 中查询封装的简化逻辑 bool Db::check_login(const std::string username, const std::string password) { auto conn pool_.get_connection(); if (!conn-ping()) { conn-reconnect(); } std::string sql SELECT password_hash FROM users WHERE username ?; auto stmt conn-prepare(sql); stmt-bind_string(0, username); auto result stmt-execute(); if (result-next()) { std::string hash result-get_string(password_hash); // 用同样的盐值再做一次哈希比对结果 return compute_hash(password) hash; } return false; }这里有两个关键点。一是 SQL 注入源码所有查询都走 prepared statement而不是字符串拼接这一点在课程设计项目里很难得不要因为演示项目就放松。二是密码存储数据库里不存明文注册时先加盐再做哈希登录时用同样参数重算比对如果你二开要增加“记住密码”功能也请在 token 里用随机数别把密码塞回去。登录成功之后做的事是开启一个游戏会话session.hpp 里保存 uid、username、当前房间号等字段前端后续所有操作都通过这个 session 里的 uid 去关联用户身份。这个设计让消息处理函数不需要每次都查库里确认身份但副作用是 session 生命周期必须管理好这就是后面避坑章要讲的掉线问题。3.2 匹配建房matcher 撮合与 room 的广播时机匹配在 matcher.hpp 里实现核心是一个等待队列。玩家点“开始匹配”后服务端把 uid 放入队列一旦队列里凑够两人就创建房间并把两个 session 都绑进去。这个源码用的先到先得策略不做段位区分好处是逻辑极简坏处是对手水平不可控二开想加“同段位优先匹配”可以在队列里存带分数的结构体按分数区间分组。// matcher.hpp 中的匹配核心逻辑 void Matcher::on_player_enter(int uid, std::shared_ptrSession session) { // 防止同一个 uid 重复入队 if (in_queue_.count(uid)) return; waiting_.push(uid); in_queue_.insert(uid); session-set_status(SessionStatus::MATCHING); if (waiting_.size() 2) { int player_a waiting_.front(); waiting_.pop(); int player_b waiting_.front(); waiting_.pop(); in_queue_.erase(player_a); in_queue_.erase(player_b); // 创建房间并把两个 session 都挂进房间 int room_id room_manager_.create_room(player_a, player_b); session_map_[player_a]-set_room_id(room_id); session_map_[player_b]-set_room_id(room_id); } }这段代码里in_queue_是一个 set作用是把重复入队请求挡在外面。实际运行中玩家可能会连续点两次“匹配”如果没有去重同一个 uid 会进队两次导致一个人被匹配进两个房间棋盘状态就乱了。这个细节值得记住。匹配成功之后room.hpp 里会向两个 session 各广播一条{cmd:match_success,room_id:xxx,opponent:用户名}消息前端收到后跳转对局页并初始化 15×15 棋盘。3.3 落子与胜负判定棋盘校验和四方向连续计数对局逻辑集中在 room.hpp棋盘用二维数组存落子消息格式是{cmd:put,x:7,y:8,color:1}。服务端收到落子请求后要做三重校验坐标在 0 到 14 之间、该位置为空、当前确实是这位玩家的回合。校验通过就更新棋盘并广播给双方然后立刻做胜负判定。// room.hpp 中落子后的胜负判定核心逻辑 int Room::check_win(int x, int y, int color) { // 四个方向水平、垂直、主对角线、副对角线 int dx[4] {1, 0, 1, 1}; int dy[4] {0, 1, 1, -1}; for (int dir 0; dir 4; dir) { int count 1; // 正方向数 for (int step 1; step 5; step) { int nx x dx[dir] * step; int ny y dy[dir] * step; if (nx 0 || nx 15 || ny 0 || ny 15) break; if (board_[nx][ny] ! color) break; count; } // 反方向数 for (int step 1; step 5; step) { int nx x - dx[dir] * step; int ny y - dy[dir] * step; if (nx 0 || nx 15 || ny 0 || ny 15) break; if (board_[nx][ny] ! color) break; count; } if (count 5) return color; // 当前颜色获胜 } return 0; }这版判定逻辑只检查以刚落子点为中心的四条线不需要扫描整个棋盘效率上没问题。需要注意的地方是坐标边界棋盘是 15×15index 从 0 到 14写循环时边界条件很容易错如果 nx 或 ny 越界了还没 break就会读到数组外的内存。另外一个常见问题是在反方向数之前没有重置 count正方向已经数到 3反方向又数到 4合计 7实际五子棋只要中间有空位就不算连成五子所以正反方向从落点出发分别数中途一旦遇到空位或对方棋子就停止这样最稳妥。源码里这个逻辑是对的二开时保持即可。胜利判定之后room.hpp 会广播胜负消息并把房间状态置为 END然后释放房间资源。这一步如果漏掉房间对象会一直驻留在内存里在线人一多内存就持续上涨。4. 浏览器端实战WebSocket 连接、心跳保活与消息协议对表4.1 页面结构login、大厅、房间三页的职责划分前端放在 wwwroot 下四个 HTML 文件分工明确login.html 是登录页注册和登录共用这个页面game_hall.html 是对战大厅展示在线玩家、提供匹配按钮和全局聊天game_room.html 是对局页包含 15×15 棋盘、聊天框、认输按钮。register.html 看起来是登录页的补充页或单独注册入口实际使用时可以从 login.html 切过去。三个页面各自都有配套的 css 和 js 文件样式和逻辑分离。这种页面划分适合课程设计答辩每个页面功能单一讲解时思路清晰。前端连接 WebSocket 的地址采用相对路径ws://location.host这样部署时不需要在前端代码里写死 IP 和端口换服务器也不用改前端文件一个很实用的细节。// wwwroot/js/ws_client.js 中的连接初始化 const WS_URL ws:// location.host /ws; function createWebSocket(onMessage) { let ws new WebSocket(WS_URL); ws.onopen function () { console.log([ws] connected); // 连接建立后首次登录取 uid sendMessage(ws, { cmd: login, username: getUsername(), token: getToken() }); }; ws.onmessage function (evt) { let msg JSON.parse(evt.data); handleServerMessage(msg); }; ws.onclose function () { console.warn([ws] closed, try reconnect in 3s); setTimeout(function () { createWebSocket(onMessage); }, 3000); }; return ws; }这里有个值得注意的点WebSocket 的 URL 路径必须和服务端的 accept 逻辑对齐。源码服务端监听时对/ws路径做升级响应其他路径直接返回 404所以前端连的是一进服务器的入口路径不是某个静态文件的路径。很多人第一次写 WebSocket 会把 URL 写成静态页面的路径比如/game_room.html结果握手一直失败这是手写 WebSocket 服务端最常见的新手错误。onclose 里的 3 秒自动重连也很有用服务端重启后浏览器能自己恢复不用手动刷新页面。4.2 消息协议cmd 字段驱动的 JSON 报文格式前后端通信用 JSON每条消息都有一个cmd字段标明消息用途服务端根据 cmd 分发处理。我把源码里用到的消息类型整理成一张对表二开添加新功能时照着扩展cmd方向关键参数用途register客户端 → 服务端username, password, nickname注册新账号login客户端 → 服务端username, password登录并绑定 sessionenter_hall客户端 → 服务端token进入大厅拉取在线列表match_start客户端 → 服务端-加入匹配队列match_cancel客户端 → 服务端-取消匹配put客户端 → 服务端x, y, color落子chat双向text房间内或大厅聊天ping / pong双向t心跳保活game_over服务端 → 客户端winner, board胜负结算协议设计的学习点在两个地方。第一是所有 cmd 都用字符串而不是数字可读性好调试时抓一条消息就知道在干嘛代价是消息体积略大但五子棋这种低频交互完全无所谓。第二是服务端下发的所有消息都带一个统一的ts时间戳字段前端做消息去重和经验断延时延时就靠它。我见过不少课程设计项目把时间戳省了结果对局同步出问题后没法定位是传输延迟还是逻辑 bug有这字段会省很多事。4.3 心跳与断线重连在浏览器里把连接保住的细节WebSocket 的 TCP 连接在空闲一段时间后可能被中间设备回收而双方都不知道连接已经死了这时候发消息不会报错只是永远没人应答。解决手段就是心跳客户端定时发 ping服务端回 pong服务端如果长时间收不到任何消息就判定连接失效并主动断开。源码前端的心跳实现如下。// wwwroot/js/ws_client.js 中的心跳与重连 let heartbeatTimer null; function startHeartbeat(ws) { stopHeartbeat(); heartbeatTimer setInterval(function () { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ cmd: ping, t: Date.now() })); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } }心跳间隔 30 秒是前端里的默认配置服务端通常在 60 秒内收不到任何数据就断开并清理 session。注意这里的“任何数据”指的是包括 ping 在内的所有帧不只是业务消息。这样设计的好处是对局过程中双方频繁落子心跳包可以被落子消息替代不用额外发。前端收到 pong 时也不做特殊处理因为 TCP 本身保证消息有序能收到 pong 就说明链路是通的。断线重连需要保存玩家当前所在房间号重连成功后服务端判断 session 是新连接但 uid 还在某个房间就把新连接重新挂回旧房间这一步源码里通过 session 的 reconnect 逻辑处理。这里有个前端技巧断线重连成功之后第一次登录消息里要把旧房间号也带上服务端才能恢复对局状态。makefile 里的编译参数我已经验证过不加-lssl -lcrypto编译时 sha1 相关的函数会链接失败。# 第 1 步编译 make # 第 2 步启动服务 ./gobang ./settings.json # 第 3 步验证握手是否正常 curl -i -X GET http://127.0.0.1:8080/ws \ -H Connection: Upgrade \ -H Upgrade: websocket \ -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ -H Sec-WebSocket-Version: 13提示settings.json 里的 port 字段控制监听端口改成 8080 或任何你方便的都行前端用相对路径所以不用跟着改。curl 收到的响应应当是HTTP/1.1 101 Switching Protocols同时带Sec-WebSocket-Accept头这代表服务端握手代码工作正常。如果返回 404检查服务端监听的路径是否和/ws匹配如果返回 501 或空白响应检查 GUID 拼接是否用了协议固定的那个常量。6.2 用两个浏览器完成一次完整对局验证验证编译无误后按下面的顺序跑一遍完整流程每步都有明确预期结果用 Chrome 打开登录页注册一个新账号再登录预期进入大厅页右上角显示自己的昵称。用 Edge或隐身窗口 Chrome注册第二个账号登录同样进入大厅。两个账号各自点“开始匹配”预期半秒内弹窗提示匹配成功页面跳转到 game_room.html棋盘初始化 15×15。黑方在棋盘中心落一子白方页面几乎同时看到棋子出现观察 WebSocket 面板里put消息的收发时间差。在下完之前打开 DevTools 的 Network 面板找到 WS 连接点击 Messages 标签你能看到客户端发的put和服务端广播的put两条消息格式完全一致时代表协议对表正确。黑方故意在角落连下五子用白方配合预期服务端广播game_over双方页面弹出胜负结算框。这六步做完这份源码的编译、启动、握手、业务流程就全部验证完了。我个人的习惯是每次改完协议都强制走一遍这四步curl 握手验证、注册登录、一次匹配、一次完整对局四步全过我才敢把改动合并。这套验证流程看着笨但确实帮我拦下过好几次低级错误比如改了 db.hpp 忘了重新编译或者把 put 消息的坐标字段从 x/y 改名导致前端解析失败。希望帮到你。本文还有配套的精品资源点击获取