简介:一套基于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 API(Windows 下叫 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 发起悔棋请求,服务器把请求转发给玩家 B,B 同意后服务器回滚棋盘状态,然后轮到 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::queue<NetMessage> 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_guard<std::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 难找十倍,而日志是唯一能帮你还原现场的工具。
本文还有配套的精品资源,点击获取