简介:一份完整的网络联机五子棋游戏源码,基于C++与Qt框架开发,适合用于网络编程、Qt界面设计及Socket通信的进阶练习与课程设计参考。项目划分为客户端与服务端:客户端界面由Qt构建,采用Windows平台C++ socket负责联机通信;服务端运行于Linux,基于Linux socket编程实现,整体可完成公网对局,有助于理解跨平台C/S架构、协议设计及回合制游戏同步流程。压缩包共28个文件,以cpp源文件、h头文件、ui界面文件为主,附带qrc资源文件、pro工程文件和makefile构建脚本,以及png、jpg图片和ico图标等界面素材,总大小仅559KB,目录简洁便于快速定位代码。内容上覆盖从主菜单、对局界面到网络收发与胜负判定的完整实现,适合有一定C++基础的学习者对照源码梳理Qt与socket协同工作方式。目前已有1399人学习下载,具备较好的参考价值。
1. 网络联机五子棋:一个 C++ QT 小游戏的落地场景
你坐在工位上手拿一杯咖啡,对面同事也启动同一个程序,你们俩没人在同一局域网里,却能在一张棋盘上轮流落子,界面流畅得不像话——这就是这个 C++ QT 五子棋联机项目最直接的体验。它不是用 WebSocket 塞进浏览器,也不是用 Python 快速糊一个 socket 服务,而是老老实实基于 C++ 标准库和 QT 的 QtNetwork 模块,在 TCP 协议上自创了一套轻量通信规则。
这个源码包适合两类人收下:一是刚学完 C++ 语法、想看看结构体和类真正怎么组织业务逻辑的在校生;二是工作中要快速搭建一个局域网对战工具、又不想引入庞大游戏引擎的开发者。它把网络通信、界面渲染、游戏规则三条线拆得清清楚楚,没有花哨的渲染,没有隐藏的依赖,所有代码都是你装好 QT 后就能直接编译的样板。
2. 技术栈与整体架构:QT Widgets + TCP Socket 组成的 C/S 结构
2.1 技术选型:为什么是 C++ 和 QT,而不是更「轻」的方案
做网络联机游戏,最容易想到的是网页版或者 Python + Tkinter,但这两个方案都有一个尴尬的点:要么依赖浏览器环境,要么在跨平台打包时被解释器拖累。这个项目选 C++ 加 QT,核心原因是 QT 把 GUI 和网络模块原生集成在一起,不需要额外安装像 libcurl 或 OpenSSL 这样的第三方库,直接用QTcpServer和QTcpSocket就能实现双向通信。
从编译产物来看,C++ 直接生成可执行文件,双击就能跑,这在局域网环境里非常友好。另一个理由是 QT 的QWidget或QPainter画棋盘非常简单,重写一个paintEvent就能把 15×15 格子画出来;反过来如果用 Python 做同样的界面,界面响应速度和 CPU 占用都会差一些。所以这个项目把界面层、网络层、游戏逻辑层做了清晰分层,每层之间只通过信号槽或消息结构体通信。
2.2 目录结构与模块边界:谁负责连接,谁负责画棋盘
拿到源码包后你会看到典型的 Qt 工程结构,这里按照功能拆成三个部分,各自的职责完全不重叠:
gomoku/ ├── server/ # 服务器程序(QWidget 或控制台模式) │ ├── server.pro │ ├── main.cpp │ ├── game_server.h # 继承 QTcpServer │ ├── game_server.cpp │ └── room.h # 维护一个房间的双方 socket ├── client/ # 客户端程序(带界面) │ ├── client.pro │ ├── main.cpp │ ├── login_dialog.h # 连接服务器、输入房间号 │ ├── board_widget.h # 继承 QWidget,负责画棋盘和响应鼠标 │ ├── network_client.h # 封装 QTcpSocket │ └── board_widget.cpp └── common/ ├── protocol.h # 消息类型枚举和结构体定义 └── game_logic.h # 五子棋规则判定(静态函数)服务器端负责监听端口、创建房间、转发双方消息,它不关心棋盘长什么样;客户端负责发送落子坐标、收到对手坐标后重绘;common目录是所有模块共享的公共部分,里面定义了消息类型(比如落子、胜利、重开、心跳)以及通用棋子状态。这样的分层让你在改某一块代码时不需要牵连其他部分,比如想改成 19×19 的棋盘,只需要改board_widget的绘制逻辑和game_logic里的边界判断,网络消息完全不用动。
一个值得注意的点是,服务器本身也带了一个简单界面,这样双端可以在同一台电脑上启动两个进程,方便调试。如果你不需要服务器界面,可以把server.pro里的QT += widgets删掉,改成纯控制台输出,不影响通信逻辑。
2.3 通信协议设计:结构体封包,一眼就看懂的消息格式
网络通信最忌讳各发各的字符串,项目里用了固定结构的二进制协议。所有消息都放在一个公共头文件里,用枚举区分类型,结构体承载数据:
// common/protocol.h #pragma once #include <QtGlobal> enum MessageType : quint8 { MT_LOGIN = 1, // 客户端 -> 服务器:请求进入房间 MT_LOGIN_OK = 2, // 服务器 -> 客户端:登录成功 MT_MOVE = 3, // 客户端 -> 服务器 / 服务器转发的落子 MT_WIN = 4, // 服务器 -> 客户端:某个玩家胜利 MT_OPPONENT_LEFT = 5 // 服务器 -> 客户端:对方掉线 }; #pragma pack(push, 1) struct MoveMessage { quint8 type; // 类型,固定为 MT_MOVE quint8 row; // 行坐标,0~14 quint8 col; // 列坐标,0~14 quint8 player; // 玩家编号:1 或 2 }; #pragma pack(pop)这里用了#pragma pack(push, 1)让结构体紧凑对齐,避免因字节填充导致读错字段。实际发送时,客户端会构造一个MoveMessage,通过 socket 的write直接写二进制数据,接收端只要检查type字段就知道接下来该解析多少字节。
之所以不用 JSON,是为了减少粘包拆包的麻烦。二进制结构体的长度固定,只要read到sizeof(MoveMessage)个字节就能完整解析。如果网络状态差,可以用QDataStream配合waitForReadyRead做缓存,后续在避坑章节我会专门讲这个。
3. 核心模块拆解:一个落子从鼠标点击到对手棋盘刷新的完整链路
3.1 服务器端:房间管理与消息中转
服务器代码最核心的部分是继承QTcpServer并重写incomingConnection,每个新连接进来时,不是立刻分配房间,而是先判断当前是否有等待中的玩家。如果还没有人,就把它标记为「等待者」;如果有等待者,就创建房间并把两个 socket 关联在一起。
// server/game_server.cpp void GameServer::incomingConnection(qintptr socketDescriptor) { QTcpSocket *socket = new QTcpSocket(this); socket->setSocketDescriptor(socketDescriptor); connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { // 读取消息头,判断是登录还是落子 socket->read((char*)&msg, sizeof(msg)); handleMessage(socket, msg); }); if (waitingSocket == nullptr) { waitingSocket = socket; // 告知客户端「你是玩家1,等待对手」 sendLoginResult(socket, 1, false); } else { // 匹配成功,告知双方开始游戏 sendLoginResult(waitingSocket, 1, true); sendLoginResult(socket, 2, true); waitingSocket = nullptr; } }waitingSocket是一个成员变量,只保留第一个连接。当第二个连接进入时,直接配对,然后重置waitingSocket等待下一组玩家。注意这里采用「先到先得」的简单策略,如果你要支持多房间,可以改成用一个QList保存所有未配对 socket,或者按房间号匹配,但核心思想一样。
服务器收到落子消息后,需要判断当前是不是该玩家落子。最简单的方法是在服务器记录每个房间的当前回合编号,每次收到落子都对比一下。更偷懒的方式是直接信任客户端,不在服务器做校验,但这个项目为了体现训练价值,会在handleMessage里加入回合检查。
3.2 客户端:棋盘绘制与落子事件捕获
客户端界面重点在BoardWidget类,它继承QWidget,重写两个函数:paintEvent负责把所有棋子重绘,mousePressEvent负责把鼠标点击位置换算成棋盘网格坐标。
// client/board_widget.cpp void BoardWidget::mousePressEvent(QMouseEvent *event) { // 计算点击落在哪个格子,不直接使用像素坐标 int x = event->pos().x(); int y = event->pos().y(); int row = (y - BOARD_MARGIN) / CELL_SIZE; int col = (x - BOARD_MARGIN) / CELL_SIZE; if (row < 0 || row >= GRID_SIZE || col < 0 || col >= GRID_SIZE) return; if (board[row][col] != EMPTY) return; // 已有棋子的位置不允许再落子 // 本地立即显示,同时发送给服务器 board[row][col] = myPlayerId; emit moveMade(row, col); update(); }这里的BOARD_MARGIN是棋盘边缘留白,CELL_SIZE是每个格子的像素宽度。实际项目里会定义成常量,比如 15 行棋盘,格子大小 40 像素,棋盘总尺寸是 15×40 + 两边留白各 15 像素。
本地立即显示棋子而不是等服务端回包,是为了消除网络往返的延迟感。用户点击后马上看到自己的棋子落下去,体验上更流畅。对手的棋子是从网络消息过来后,在handleMoveMessage里写进board数组再调用update()刷新界面。注意这里要区分myPlayerId,在登录成功时服务器会告诉客户端是玩家 1 还是玩家 2,这个 ID 同时决定了谁先手。
3.3 游戏逻辑:五子棋胜负判定算法
胜负判断写在game_logic.h中,是一个纯静态函数,不依赖界面和网络。它的输入是棋盘二维数组和最后落子的位置,输出是是否赢棋。最简单的判定方法是沿四个方向各自数连续子数,四个方向分别是水平、垂直、两条对角线。
// common/game_logic.h static bool checkWin(const quint8 board[15][15], int row, int col, quint8 player) { const int direction[4][2] = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d = 0; d < 4; ++d) { int count = 1; int dr = direction[d][0], dc = direction[d][1]; // 正方向延伸 for (int i = 1; i < 5; ++i) { int nr = row + dr * i, nc = col + dc * i; if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15 || board[nr][nc] != player) break; ++count; } // 反方向延伸 for (int i = 1; i < 5; ++i) { int nr = row - dr * i, nc = col - dc * i; if (nr < 0 || nr >= 15 || nc < 0 || nc >= 15 || board[nr][nc] != player) break; ++count; } if (count >= 5) return true; } return false; }这段代码的关键在于先固定一个方向,然后分别朝正反两个方向数,最后把两边的数量加起来。很多新手会犯的错误是只数了一边,导致斜着连成一排时判断失败。这里用循环内直接加减来避免重复递归,效率很高,一遍扫描就能得到结果。
在实际项目里,这个函数会被服务器调用,因为服务器维护着棋盘的逻辑副本。当服务器判定某方胜利,就广播一个MT_WIN消息给双方,客户端收到后弹出「你赢了/你输了」的提示并禁止继续落子。同样的函数也可以在客户端本地用来做「悔棋校验」或「人机提示」。
4. 项目运行与配置:从零编译到手把手启动双端
4.1 环境准备:QT 版本、编译器和 qmake/CMake 的选择
这个项目是标准的 QT Widgets 应用,推荐使用 QT 5.15 或更高版本,编译环境可以是 Windows 上的 MinGW 或 MSVC,也可以是 Linux 下的 g++。源码包里提供了.pro文件,说明作者使用 qmake 构建。如果你更习惯 CMake,也可以根据.pro里的源文件列表转过去,但第一次跑通建议直接用 qmake,因为它默认就会包含 QT 的模块路径。
需要注意,客户端和服务器是两个独立的可执行文件。编译时先分别进入server和client目录,执行:
# Linux / macOS qmake server.pro -o Makefile.server make -f Makefile.server # Windows 下也可以用 Qt Creator 直接打开 .pro # 或者打开终端用 mingw32-make如果你用 Qt Creator,直接打开gomoku.pro(如果根目录有一个汇总工程),它会自动加载子工程。实际使用中我一般会创建两个构建套件,把客户端和服务器分别编译成 debug 版,方便看调试输出。
4.2 启动时的端口与 IP 配置
服务器默认监听127.0.0.1:9999,这样单机调试最方便。如果要真正跨电脑联机,得把服务器端的监听地址改成0.0.0.0,或者注释掉绑定 IP 只绑定端口。在代码里通常是这一行:
if (!server->listen(QHostAddress::AnyIPv4, 9999)) { qDebug() << "监听失败:" << server->errorString(); }9999是随便定的,你可以改成40000之类的非特权端口。客户端的连接地址在登录界面里输入,默认是一个测试用的 IP。为了方便,项目里会把上次连接成功的 IP 保存到QSettings,下次启动自动填充。
4.3 验证联通性的两个小技巧
启动服务器后,先有一个黑色控制台窗口显示「服务器已启动」。客户端登录框填127.0.0.1,点连接,如果服务器界面或日志显示了「玩家1加入房间」,那说明本地通信 OK。接着再开一个客户端,填同样的 IP,第二个客户端会直接进入棋盘并显示你是玩家2——这时候整个链路就通了。
如果是两台电脑联机,先确认防火墙允许9999端口放行。Windows 下会用ping命令测通因特网连接并不代表 TCP 能通,最好用telnet IP 9999做一次探测。如果telnet能连上再打开客户端,否则一定连不上。
5. 避坑与常见问题:编译、连接和界面上的那些坑
5.1 编译报错:QT 版本不匹配导致的「找不到 Q_OBJECT 宏」
现象:用高版本 QT 打开.pro文件后,编译时提示moc文件生成错误,或者undefined reference to vtable for LoginDialog。
原因:Q_OBJECT宏需要 moc 编译器预处理,新版 QT 的 moc 路径和旧版不同,如果 qmake 生成的 Makefile 里MOC_DIR位置不对,或你的工程里混用了 qt4 和 qt5 的模块名称,就会找不到信号槽函数。
解决:统一使用 QT 5.15 的 Reinforced Qt Kit。打开.pro文件,确认QT += core gui network widgets这一行存在,然后执行make clean再重新构建。如果依然报错,手动删除debug/build目录下所有moc_*和*.o文件,让 make 强制重来。
5.2 连接失败:服务器绑定 IP 写成了 127.0.0.1
现象:在公司内网,客户端填了服务器的局域网 IP 却总是显示「连接服务器失败」。
原因:服务器端用了QHostAddress::LocalHost,它只允许本机回环地址连接,外部设备无法访问。
解决:代码中改用QHostAddress::AnyIPv4,或监听0.0.0.0。另外,如果服务器和客户端都是同一台电脑调试,用127.0.0.1没问题,但上来就做跨机测试就要检查这一行。我经常看到一个新手把监听地址设成自己电脑的局域网 IP,但电脑的 IP 会因 DHCP 改变,导致换网络后就服务失效,这种情况直接写AnyIPv4最安全。
5.3 界面刷新卡顿:用了repaint()而不是update()
现象:对手落子消息频繁到达时,棋盘偶尔闪烁,或者主界面短时间无响应。
原因:重写paintEvent时,在消息处理函数里直接调用repaint()强制刷新,而repaint()会同步触发paintEvent,如果网络消息在非 GUI 线程处理,就可能和 GUI 线程冲突。
解决:所有从QTcpSocket::readyRead信号传来的数据,不应在槽函数里直接调用界面刷新操作。正确做法是先把消息解析后存入一个队列,再调用update()收集绘制事件,由 QT 事件循环统一调度。在我的代码里,handleMoveMessage只修改boardPlayer[15][15]然后调用update(),不在槽函数内部弹框或做耗时循环。
5.4 粘包问题:消息一次来了两个怎么办
现象:快速连续落子时,偶尔出现一次读到了两个MoveMessage,导致棋子位置错乱。
原因:TCP 是流协议,一次read可能读取到多个消息拼接的数据。项目里用固定结构体长度,理论上可以简单按长度切片,但没有处理「只收到半个包」的情况。
解决:在 socket 上维护一个QByteArray缓冲区,每次readyRead先append,然后循环检查缓冲区中是否有至少sizeof(MoveMessage)字节的数据,有就取出并remove掉已消费的部分。这个缓存逻辑在真实项目中必不可少,否则测试时偶尔没问题,压力一大就翻车。
5.5 客户端掉线后服务器崩溃
现象:玩家关闭窗口,服务器write到已断开的 socket 会报错,严重的会让整个监听端退出。
原因:没有处理disconnected信号。在GameServer构造或incomingConnection中,只有readyRead连接没有disconnected连接。
解决:每个 socket 都连接一个槽,在其中判断该 socket 是否为waitingSocket,如果是,则清空;如果是某个房间中的一员,则通知对方「对手离线」,并销毁双方 socket,释放房间资源。这是网络编程必备的兜底逻辑,不然你的服务器就成了碰运气。
6. 二次开发与改造思路:从五子棋到更强的联机框架
基础版跑通后,你可以试试给它加几个实用功能,我挑两个最有代表性的写一下:悔棋和聊天。你会发现网络联机小游戏的核心不在算法,而在消息类型的扩展和双端状态同步。
第一个改动是增加MT_CHAT消息,定义结构体:
struct ChatMessage { quint8 type; // MT_CHAT char text[256]; };在服务器转发时不需要读取内容,直接把ChatMessage当QByteArray原样转发给对手即可。客户端在move事件之外读取到MT_CHAT后,把它追加到聊天框。这个功能只需要改protocol.h、服务器转发代码、客户端处理函数三处,不用动棋盘绘制,非常安全。
第二个改动是悔棋,这会更繁琐一些。我的习惯是在客户端本地保存历史落子栈,发送悔棋请求时,本地先把自己的最后一手和上一手(如果有)弹出,再发送自定义消息MT_REQUEST_UNDO给服务器,服务器转发给对手。对手同意后,由服务器统一发出MT_UNDO_CONFIRM,双方才执行栈的回退。如果不经过对手同意就直接撤销,会造成双方棋盘状态不一致。这里一定要记住:所有状态修改必须由服务器或双方协商完成,不能只改自己界面。
改造完成后,我习惯每次跑一遍完整的双开测试流程:启动服务器,两个客户端同时连接,落子 20 手无异常,然后点悔棋 5 次,再正常走完一局。从那以后我每次改协议前,都会先画一张消息时序图,确认每个消息的到达顺序和可能的失败分支,再动手写代码。这个习惯帮我避开了无数个「这边发了那边没收到」的坑。希望帮到你。
本文还有配套的精品资源,点击获取