C++网络编程实战:基于Socket与多线程的局域网联机五子棋

C++网络编程实战:基于Socket与多线程的局域网联机五子棋 简介一套基于C与QT框架开发的网络联机五子棋游戏源码面向有一定C基础、希望深入学习网络编程与界面开发的读者。项目将客户端与服务端独立拆分客户端使用QT完成棋盘、按钮和菜单等界面设计并通过Windows平台socket发送落子数据服务端部署在Linux系统借助Linux socket处理对局逻辑。整体基于C/C实现能够支持公网联机对战适合作为课程设计、毕业设计或游戏开发入门参考。资源压缩包共28个文件其中6个cpp源文件和4个头文件构成核心逻辑3个ui文件对应界面布局qrc与png/jpg/ico等素材管理界面图片pro与makefile文件分别提供QT工程配置和服务端构建入口包体仅559KB结构清晰便于按需阅读。该资源已有1398人学习下载对于想快速跑通一套联机小游戏的同学可以从中获得完整的通信协议设计思路、QT界面布局方式以及跨平台socket编程的具体实现同时也能看到客户端与服务端协作的整套代码组织方式。1. 项目概述与整体设计思路网络联机五子棋这个名字听起来好像不算难但真正动手写过一套能稳定跑起来、支持局域网对战的 C 程序你就会发现它把 C 开发里最“接地气”的几个硬骨头全都串起来了Socket 编程、多线程同步、协议设计、还有游戏逻辑的状态管理。我最初做这个项目是为了应付课程设计后来陆续有读者拿着它去交作业、应付面试项目经验、甚至改一改做成公司内部的小工具我才意识到这种“小而全”的练手项目其实是一个性价比极高的 C 综合训练场。很多初学者容易踩一个误区一上来就纠结界面到底用 Win32 API、Qt 还是 MFC或者动不动就要上 IOCP、epoll 这些高阶网络模型。我的建议很直接——如果你只是想理解网络联机游戏的核心链路那就先做一个控制台版本或者最简单的窗口版本把网络通信和棋局逻辑跑通再说这才是真正的核心价值所在。至于界面美观程度那是锦上添花的事情完全不是这个项目的主线。这个项目的目标读者大概是这么几类正在学 C 但是觉得语法枯燥的学生准备找服务端开发或者游戏客户端开发方向实习的求职者以及想在公司内部快速搭一个局域网休闲小工具的程序员。不管你是哪一种我都能负责任地告诉你只要把下面这套代码和思路吃透你掌握的不只是一盘五子棋怎么联机而是一整套“客户端-服务器交互”的通用套路以后做任何联机功能都能复用这个框架。技术选型上我使用了最经典的 Berkeley Socket APIWindows 下叫 Winsock配合 TCP 协议。为什么不选 UDP道理很简单五子棋是回合制游戏对数据可靠性要求高丢一个包可能导致整个棋盘状态错乱而 TCP 帮我们省掉了处理丢包重传的心智负担让我能把精力集中在游戏逻辑本身。当然这不代表 UDP 没用后续如果你要做实时动作类游戏UDP 才是需要考虑的方向但那是后话。2. 核心数据结构与对局逻辑实现2.1 棋盘怎么存二维数组的取舍五子棋的棋盘规格最常见的是 15×15也有用 19×19 的但 15×15 在策略深度和代码表达上都比较合适。我先用最直接的方式定义一个枚举和棋盘数组enum ChessType { EMPTY 0, BLACK 1, WHITE 2 }; const int BOARD_SIZE 15; ChessType board[BOARD_SIZE][BOARD_SIZE];这里的ChessType枚举比直接用int好读得多。实际开发中我还见过有人用char数组然后存B、W、0这样的字符也完全没问题看个人习惯。核心思路是棋盘就是一个二维状态矩阵每个格子只可能处于三种状态之一空、黑、白。这种建模方式不仅适用于五子棋像围棋、黑白棋、甚至简单的地图寻路都可以套用。有人可能会问为什么不用一维数组然后通过下标换算访问比如int board[225]访问第 row 行第 col 列时用board[row * BOARD_SIZE col]。这也是常见的优化思路在后续做 AI 搜索比如极小化极大算法时一维数组的缓存友好性确实会好一点。但初次实现我强烈建议用二维数组它让代码可读性高得多不容易出现下标换算错误等你真到了需要性能抠细节的阶段再改不迟。2.2 胜负判断只查落子点附近四个方向很多人写五子棋胜负判断会遍历整个棋盘的所有格子对每个格子往四个方向看五个同色棋子。这种做法当然没错15×15 的棋盘只有 225 个格子遍历一次性能上毫无压力。但有个更优雅的思路每次落子是唯一改变棋盘状态的事件所以胜负只可能和刚落的这颗子有关。我只需要以落子点为起点沿着四个方向水平、垂直、两条对角线分别数连续同色棋子的数量只要某个方向连续数量大于等于 5就分出胜负。bool checkWin(int row, int col, ChessType player) { int directions[4][2] { {1, 0}, // 水平方向向右 {0, 1}, // 垂直方向向下 {1, 1}, // 主对角线右下 {1, -1} // 副对角线左下 }; for (int i 0; i 4; i) { int count 1; // 正方向数 for (int step 1; ; step) { int nr row directions[i][0] * step; int nc col directions[i][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE || board[nr][nc] ! player) break; count; } // 反方向数 for (int step 1; ; step) { int nr row - directions[i][0] * step; int nc col - directions[i][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE || board[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }这里有个细节值得注意方向数组里我只定义了四个方向的增量因为正反方向合在一起才是完整的一条线。比如水平方向我只写了{1, 0}但它代表的是从落子点向右偏移反方向则是向左偏移。四个方向数组配合正反两个循环就能覆盖四条完整的直线。这个思路在你以后写连连看、消消乐这类“连续匹配”游戏时可以直接复用。2.3 落子、悔棋与对局状态管理落子逻辑本身不复杂核心是三个动作判断位置是否合法在棋盘内且未落子、更新棋盘数组、检查胜负。但联机模式下这个看似简单的流程需要追加一个关键约束只有轮到当前回合的玩家才能落子否则就属于非法操作。悔棋是另一个有意思的设计点。说实话联机模式下做悔棋要比单机麻烦得多因为悔棋本质上是一个双方协商的动作——不是我想悔就能悔必须对方同意否则受影响的玩家会觉得不公平。我见过最简单的实现是玩家 A 发起悔棋请求服务器把请求转发给玩家 BB 同意后服务器回滚棋盘状态然后轮到 A 落子B 拒绝则棋盘不变继续轮到 B。这种设计在逻辑上干净也避免了两个客户端状态不一致的问题。注意悔棋必须“回滚两步”A 上一手和 B 上一手而不是只有发起方的一手否则局面就是一边赚了便宜。3. 网络联机模块的实现细节3.1 自定义通信协议比你想的更简单但也更讲究联机游戏的核心是客户端和服务器之间的消息通信。这里最忌讳的做法是直接传“坐标字符串”比如7,12因为一旦消息多了解析起来非常痛苦且容易出错。我建议从第一步就约定一个二进制协议结构体并且用枚举值区分消息类型。enum MsgType { MSG_PLACE_CHESS 1, // 落子 MSG_GAME_OVER, // 游戏结束 MSG_RESTART, // 重新开始 MSG_CHAT, // 聊天 MSG_UNDO_REQUEST, // 悔棋请求 MSG_UNDO_RESPONSE // 悔棋响应 }; struct NetMessage { int type; // 消息类型 int row; // 行坐标 int col; // 列坐标 int player; // 玩家编号 int extra; // 附加参数比如悔棋响应中的 1同意/0拒绝 };你可以把这个结构体想象成一封信的信封收件人和寄件人地址之外内容必须按照固定的格式填写双方才能正确解读。TCP 是一个字节流协议它本身不知道“一条消息从哪里开始到哪里结束”所以消息边界的划分是协议设计的关键。上面这个NetMessage结构体如果不加处理地直接发送如果发送端连续发送多条消息接收端可能会一次性收到两次甚至三次消息拼接在一起的数据这就是经典的“粘包问题”。3.2 粘包与半包问题被问得最多的坑粘包问题几乎是每个写网络程序的人都会遇到的第一个拦路虎。我的处理方案非常经典在结构体头部增加一个长度字段。接收方先读 4 个字节得知完整消息体的长度再循环读取对应长度的字节直到凑齐一条完整的NetMessage。// 发送端在消息前拼接一个长度字段 void sendMessage(SOCKET sock, const NetMessage msg) { int dataSize sizeof(NetMessage); // 先发送消息长度再发送消息内容 send(sock, (char*)dataSize, sizeof(dataSize), 0); send(sock, (char*)msg, dataSize, 0); }接收端更复杂一些需要一个缓冲区不断把recv出来的数据追加进去然后循环判断当前缓冲区的长度是否足以构成一条完整消息。这里的“半包”问题同样常见——对方的send可能被 TCP 拆成两次发送你只收到一半。所以哪怕你已经知道一条消息有 24 字节但第一次可能只收到 12 字节仍然必须继续recv。我在这个项目里给接收缓冲区设置了一个辅助函数每次收完数据就进入“解析循环”把缓冲区内所有完整的包取出来留下的不完整部分继续等下一次数据到达。想省事的话也可以用一个简单的类封装缓冲区和解析逻辑代码会更整洁。3.3 服务端与客户端线程模型的设计与同步服务端我用的是“每客户端一线程”的模式。主线程负责socket() - bind() - listen()然后循环调用accept()接收新连接每个连接到来就创建一个新线程去处理这个客户端的所有收发。这种模式在只有两个客户端时自然毫无压力哪怕后面扩展支持 8 人、16 人同时在线也完全跑得动。客户端的结构更典型主线程负责界面渲染、鼠标点击检测和本地游戏逻辑另一个后台线程专门负责循环recv()收到网络消息后写到一个队列里主线程在游戏循环中不断检查这个队列并处理。这样做的核心原因是如果让主线程去阻塞等待recv()界面就会卡死你连关闭窗口都得靠任务管理器强制结束。既然是两个线程访问同一个队列那就不得不提同步。我用的方式是为消息队列配一个std::mutex入队和出队时都加锁。这里有个小忠告任何被多个线程共享的可变数据都要加锁没有一个例外。我自己早期曾经抱侥幸心理觉得“就一个整数不加锁也没事吧”结果跑起来一会儿程序莫名崩溃一会儿又死锁排查了半天才发现是内存竞争导致的未定义行为。C 的std::mutex用起来很简单lock()和unlock()两个函数只要记住先加锁再操作共享数据操作完马上释放锁就不会有大问题。3.4 服务器如何决定谁是黑棋谁是白棋服务器在 accept 到两个客户端之后需要给它们分配角色。最简单的策略是先接入的玩家执黑先手后接入的玩家执白后手。服务器再把这个角色信息通过消息告知两个客户端。这里有一个容易忽略的细节服务器需要维护当前轮到谁落子而不是信任客户端传来的“我是玩家 1我该落子了”这种自报家门的方式。客户端只能告诉服务器“我想在这个位置落子”服务器校验回合正确性、位置合法性之后再广播给双方“玩家 X 在 (row, col) 落子成功”。这套流程虽然多了一步但彻底杜绝了恶意客户端跳过回合直接连下两手的作弊问题也保证了两个客户端看到的状态永远一致。4. 实操过程从零搭起来的最小可运行版本4.1 环境准备与工程配置要点我用的是 Visual Studio 2019 (MSVC)只需要创建一个空的控制台项目然后在代码里加入WinSock2.h头文件并在工程属性 - 链接器 - 输入 - 附加依赖项中加上ws2_32.lib。如果你用的是 VSCode MinGW编译参数加上-lws2_32即可。这一步极其关键网络上很多人代码写得没问题但编译报一堆LNK2019链接错误基本就是漏了链接库文件。在 main 函数最前面还需要先初始化 Winsock 库这一步新手特别容易忘WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData);WSAStartup相当于告诉操作系统“我要开始使用网络库了”程序退出前记得调用WSACleanup()做清理。4.2 服务器端骨架代码// 服务端创建监听套接字的核心流程 SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_port htons(8888); // 端口号注意转网络字节序 serverAddr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 bind(listenSock, (sockaddr*)serverAddr, sizeof(serverAddr)); listen(listenSock, 5); // 循环 accept 客户端 SOCKET clientSock accept(listenSock, NULL, NULL);注意这里的两个关键函数htons和htonl它们把主机字节序转换成网络字节序。为什么需要它们因为不同 CPU 架构的字节序可能不同如果不做统一转换在跨平台联机时可能出现整数完全读错的情况。虽然 x86 架构下即使不转换大概率也能跑但养成写网络程序必须处理字节序的习惯是专业和业余的分水岭之一。4.3 客户端连接与收发线程客户端部分核心就一句SOCKET sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr inet_addr(127.0.0.1); // 局域网时填服务器IP serverAddr.sin_port htons(8888); connect(sock, (sockaddr*)serverAddr, sizeof(serverAddr));连上之后立刻启动一个子线程循环recv主线程就做界面渲染和落子点击。收发线程之间的通信我用的消息队列方式std::queueNetMessage g_msgQueue; std::mutex g_msgMutex; // 接收线程 void recvThreadFunc(SOCKET sock) { char buffer[4096]; while (true) { int ret recv(sock, buffer, sizeof(buffer), 0); if (ret 0) break; // 连接关闭或出错 // 将 ret 字节追加到缓冲区然后尝试从缓冲区解析出完整消息 // 解析出的每一条完整 NetMessage 都入队 } } // 主线程游戏循环 void gameLoop() { while (true) { std::lock_guardstd::mutex lock(g_msgMutex); while (!g_msgQueue.empty()) { NetMessage msg g_msgQueue.front(); g_msgQueue.pop(); handleMessage(msg); // 根据 msg.type 做对应处理 } } }4.4 联调运行效果与验收标准全部代码写完在同一台电脑上启动两个客户端进程一个连接127.0.0.1另一个也连接127.0.0.1服务器绑定 8888 端口。先接入的客户端执黑先行落子后另一个客户端应立即看到棋子出现交替落子连成五个后双方都弹窗提示黑方胜或白方胜。如果这些都正常恭喜你网络联机五子棋的核心功能已经达成。把这个程序扔到两台同一局域网内的电脑上把客户端的 IP 改成服务端的局域网 IP同样能跑就是真正的“网络联机”了。5. 常见问题、避坑清单与后续扩展5.1 典型问题速查表问题现象可能原因解决方案编译报错 LNK2019 ws2_32 相关符号无法解析工程没链接 ws2_32.lib属性-链接器-输入-附加依赖项加入 ws2_32.lib客户端 connect 失败返回错误码 10061服务器没有启动或端口被防火墙拦截先启动服务端再启动客户端检查防火墙玩家落子后对方没有反应收消息线程粘包解析逻辑不完整消息还留在缓冲区检查缓冲区分帧逻辑确认每条消息能完整取出程序能跑但操作几次后界面卡死主线程阻塞在了 recv 调用上把收消息操作放到独立线程主线程只处理消息队列棋盘上偶尔出现错误棋子跨子落子落子判断依赖于客户端本地状态服务器未做回合校验服务器维护当前回合仅轮到对应玩家时接受落子关掉客户端后服务器崩溃accept 出来的 socket 未关闭或 recv 返回 0 时未做清理recv 返回 0 或 SOCKET_ERROR 后关闭 socket释放资源同时启动多个客户端导致对局混乱服务器未限制只有两个客户端进入对局设为最多 accept 两个连接后不再接受新连接5.2 避坑心得阻塞与非阻塞的取舍默认情况下Winsock 的recv是阻塞模式这意味着如果服务器一直没有发数据客户端收消息线程会一直停在那里等。阻塞模式的好处是代码简单坏处是一旦对方异常断开服务端可能迟迟无法感知导致资源泄漏。我在实现中给服务端的 recv 设置了一个SO_RCVTIMEO超时值比如 500 毫秒超时后 recv 会返回SOCKET_ERROR配合WSAETIMEDOUT错误码再继续循环就能定期检查客户端是否还在线。这样做会让线程的 CPU 占用率略微升高但换来的是更可靠的生命周期管理对学习项目来说完全值得。还有一个常被忽略的坑服务端给两个客户端发送消息时要防止“一个客户端发送太快另一个客户端接收缓冲区积压”的情况。TCP 自带流量控制接收方应用层如果不及时recv发送方的send最终会阻塞。在只能在同一台机器上跑两个客户端的初学者场景下这个问题不明显但在真实局域网中网络状况差的机器可能导致发消息卡住。解决思路是增加发送缓冲区的容量或者干脆把发送也放到一个独立的发送队列里由专用线程处理。我在做第二版的时候才补上这个优化第一版维持简单就好。5.3 后续可以怎么玩从课设到简历亮点如果你做完这个基础版本还觉得不过瘾我给你列几个低成本、高收益的扩展方向第一加一个极简的登录与房间系统。那怕是硬编码用户名和密码也能让你体验到“账号系统”的流程。第二把界面从控制台换成 Qt。Qt 的QTcpSocket和表格式的信号槽机制比裸 Winsock 开发效率高不少但理解了底层 socket 原理后再用高层封装底气完全不同。第三给 AI 加一个简单的决策搜索比如利用极大极小值搜索配合评估函数做一个“人机对战”模式这在简历上会非常亮眼还能顺带复习算法。第四把棋盘大小改为可配置的 19×19 并接入围棋规则五子棋只是热身真正吞掉对方棋子的规则实现会让你对状态管理有更深的理解。我自己在写这个项目时最大的收获其实不是五子棋本身而是第一次真正体会到“多线程 网络 状态同步”三者协同工作时的那种紧张感——每一个并发问题都可能让整个程序瞬间崩溃每一次调试都逼着你更深入理解操作系统和网络栈到底在做什么。根据我的经验如果你能把这一套 800 行左右的代码完整吃透再遇到其他任何“多人协作型”小工具或游戏项目你都会比没写过的人多一份说不清道不明的底气。最后再分享一个我后来的习惯写网络程序时永远先打印一遍收发双方的关键消息日志再考虑调界面因为网络程序的 Bug 比界面 Bug 难找十倍而日志是唯一能帮你还原现场的工具。本文还有配套的精品资源点击获取