C++/Qt实战:网络联机五子棋小游戏源码全解析 简介这是一份面向想要掌握C/C网络编程和QT界面开发的学习者的联机五子棋完整工程包含QT客户端和Linux服务端两部分既能用于课程设计也可作为socket实战练习的起步模板。客户端页面基于QT搭建网络通信采用Windows平台C socket服务端运行于Linux并采用Linux socket实现支持公网对局能直观演示跨平台通信、协议交互与棋盘逻辑。压缩包共28个文件、约559KB内含6个cpp、4个h、3个ui及pro、qrc、makefile等工程配置另含9张png、ico、jpg图片素材cpp为程序逻辑h为接口声明ui为界面布局pro/qrc/makefile负责编译与资源管理目录将客户端与服务端分开便于定位代码和界面素材。目前已有1399人学习下载。通过该工程可获得客户端和服务端完整源码、UI文件及资源图片重点梳理socket建连、消息收发、落子判定、胜负同步等关键实现项目文件不多但结构清晰适合按模块逐行研读也可替换本地对战为在线对战值得收藏备用。1. 网络联机五子棋一个拿来就能跑的 C/Qt 双人对战框架周末把这份源码跑起来时我一度以为它只是个玩具一个终端窗口跑服务端两个客户端窗口连上去在局域网里对上一局五子棋。真正打开代码后我发现这套「网络联机五子棋小游戏源码(C)」把 C 游戏开发里最常碰的几块硬骨头都串起来了——Qt 棋盘重绘、QTcpSocket 异步通信、自定义二进制协议、五子连珠判定全是能直接照着改的工程代码。它适合两类人找网络编程练手项目的学生以及需要一套可复用 C/S 框架做小游戏原型的开发者。它解决的问题很具体两个进程之间怎么可靠地同步一局棋的状态让黑白双方的操作实时可见。2. 项目结构与技术选型从 QTcpSocket 到落子判定的整体骨架2.1 目录结构服务端与客户端各管一摊拿到源码第一步不是急着编译而是先看目录。这个项目把公共协议单独拎出来服务端和客户端各占一个目录整体结构是这样gomoku/ ├── gomoku.pro # 根工程也可拆成 server.pro / client.pro ├── common/ │ └── protocol.h # 消息类型、编解码函数两端共用 ├── server/ │ ├── main.cpp # QCoreApplication 入口无界面 │ ├── gameserver.h/.cpp # QTcpServer 监听、玩家配对、消息转发 │ └── player.h/.cpp # 玩家会话封装socket、角色、状态 └── client/ ├── main.cpp # QApplication 入口 ├── mainwindow.h/.cpp # 主窗口、菜单、状态栏、胜负弹窗 ├── boardwidget.h/.cpp # 棋盘绘制与鼠标落子 └── networkclient.h/.cpp # QTcpSocket 封装、协议收发这个结构的讲究在于 common/protocol.h 的存在。网络联机项目最容易出的一种事故就是服务端和客户端各写了一套消息解析字段顺序一换编译都能过联调时却互相看不懂。把协议头文件放在公共目录两端 include 同一份从根上杜绝这个问题。服务端没有 GUI用的是 QCoreApplication部署在纯命令行环境毫无压力客户端则是标准的 QWidget 程序棋盘、菜单、状态栏一应俱全。小项目这样分层已经够用common 管协议server 管连接与转发client 管界面和交互。整个数据流是单向清晰的——客户端产生落子服务端原样搬运另一个客户端解析并重绘。你要是想往生产方向走后面再抽一个独立的 logic 目录放规则引擎让 server 和 client 同时依赖它那是后话。2.2 技术选型C 与 Qt Network 的取舍选 Qt Network 而不是裸写 winsock 或 BSD socket核心原因是它的异步模型。QTcpServer 的 newConnection 信号、QTcpSocket 的 readyRead 信号配合 Qt 的事件循环天然就是非阻塞的。写网络游戏最怕的「界面卡死」用这套模型从框架层面就避开了。当然你也可以开一个线程用阻塞 socket 硬扛但那样得自己管线程安全、跨线程 UI 刷新开发量直接翻倍。另一个理由是绘图。QPainter 画一个 15×15 的棋盘代码量不超过三十行颜色、抗锯齿、重绘机制全是现成的。换成其他 GUI 框架要么绘图 API 太底层要么控件模型不适合做逐格刷新的游戏界面。C 配 Qt 在这个场景里恰好是「网络通信」和「界面绘制」两个难点都有成熟方案可组合。顺便说下编译模型。工程文件用 qmake 的 .pro 写法这是 Qt 老项目最通用的组织方式Qt Creator 双击 .pro 就能导入新版 Qt 也支持 CMake但这份源码按 qmake 组织跑起来最省事。QTcpSocket、QPainter 这两个核心模块在 Qt 5.12 到 Qt 6.x 之间 API 基本稳定跨版本迁移的成本很低这也是我推荐拿它练手的原因之一。2.3 消息协议四类报文撑起整局棋看网络程序先看协议。这个项目用的是紧凑的二进制协议四条消息类型覆盖一整局游戏// common/protocol.h —— 客户端与服务端共用一份防止协议漂移 #pragma once #include QByteArray #include QDataStream enum MsgType { MSG_HELLO 1, // 握手服务端告知角色1黑 2白 MSG_MOVE 2, // 落子携带 row、col、player MSG_RESULT 3, // 对局结束winner0 和棋否则为赢家角色 MSG_RESTART 4 // 再来一局 }; inline QByteArray encodeMessage(quint8 type, quint8 a 0, quint8 b 0, quint8 c 0) { QByteArray block; QDataStream out(block, QIODevice::WriteOnly); out.setVersion(QDataStream::Qt_6_5); out type a b c; // 固定四字节负载 return block; } inline void decodeMessage(const QByteArray block, quint8 *type, quint8 *a, quint8 *b, quint8 *c) { QDataStream in(block); in.setVersion(QDataStream::Qt_6_5); in *type *a *b *c; }每条消息固定四个字段类型加三个参数。落子时报文就是 MSG_MOVE 行 列 玩家角色服务端拿到后原样转发对面解码后更新本地棋盘。固定长度的好处是调试直观——抓包看到四个字节就知道谁在干什么不会出现解析错位。这条设计里有个细节值得注意QDataStream 的 setVersion 必须两端一致否则不同位数环境下编码结果可能对不上这是联机项目最容易踩的暗坑后面避坑章节会细讲。协议整体走的是「薄协议」路线只定义对局所需的最小消息集合不做心跳、不做加密。真要上生产环境得补心跳包检测掉线、消息序号防重放、服务端校验落子合法性——这些属于优化方向现阶段这套最小集合恰好够用来理解联机游戏的消息流转。把四个字段在脑子里的流转顺序理一遍服务端开局发 HELLO双方轮流发 MOVE五连出现发 RESULT再来一局发 RESTART整局游戏的时序就闭环了。3. 核心模块拆解一局棋从连接、落子到胜负判定的完整链路3.1 服务端监听、配对与消息转发服务端的定位是「薄转发」——不维护棋盘状态只负责把玩家连接配对然后当一个透明管道。这样设计的好处是服务端逻辑极简整份代码只有监听、配对、转发三件事坏处是防不了作弊但作为学习项目薄转发恰好能让你把注意力集中在网络链路上。// server/gameserver.cpp —— 玩家接入与配对 void GameServer::onNewConnection() { QTcpSocket *socket m_server-nextPendingConnection(); connect(socket, QTcpSocket::readyRead, this, GameServer::onReadyRead); connect(socket, QTcpSocket::disconnected, this, GameServer::onDisconnected); m_queue.append(socket); logInfo(QStringLiteral(玩家接入等待队列%1).arg(m_queue.size())); // 攒够两人就开局先进先出先到者为黑方 if (m_queue.size() 2) { QTcpSocket *black m_queue.takeFirst(); QTcpSocket *white m_queue.takeFirst(); black-write(encodeMessage(MSG_HELLO, BLACK, 0, 0)); white-write(encodeMessage(MSG_HELLO, WHITE, 0, 0)); logInfo(QStringLiteral(对局开始黑 %1 vs 白 %2) .arg(black-peerAddress().toString(), white-peerAddress().toString())); } }配对逻辑用了一个简单的 FIFO 队列新连接进队列尾部攒够两人就从头部取两个组成一局。先接入的拿黑棋后接入的拿白棋角色通过 MSG_HELLO 分别告知。客户端收到这条消息后才知道自己是先手还是后手、棋子颜色是黑是白所以整局游戏的「身份建立」全靠一条四字节报文。这里的 logInfo 是对 qDebug 的简单封装联调时看它就能确认配对是否成功。转发逻辑更直白谁发来消息就原样发给另一个人void GameServer::onReadyRead() { QTcpSocket *sender qobject_castQTcpSocket *(sender()); QByteArray data sender-readAll(); QTcpSocket *target (sender m_black) ? m_white : m_black; if (target target-isWritable()) { target-write(data); // 原样转发不做解析 } }注意这里的转发完全不解析消息内容数据被当成不透明的字节流处理。这个设计有讲究协议层增减消息类型时服务端代码一行都不用改改动只发生在客户端。对当前需求来说服务端只认「谁和谁在一局」不认「消息具体是什么」职责边界非常清爽。你要想加房间、加观战后面要做的就是把 m_queue 升级成 m_rooms 的映射管理改造方向是明确的。3.2 客户端棋盘绘制、落子交互与回合状态机客户端的核心是 BoardWidget一个继承 QWidget 的棋盘控件。它同时承担三件事画棋盘、接收鼠标落子、维护 15×15 的棋盘数组。绘制全在 paintEvent 里完成这符合 Qt 的规矩——不要主动调 paint 函数改完数据调 update()让系统决定何时重绘。// client/boardwidget.cpp —— 棋盘绘制 void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); for (int i 0; i BOARD_SIZE; i) { // 横线 painter.drawLine(MARGIN i * CELL, MARGIN, MARGIN i * CELL, MARGIN (BOARD_SIZE - 1) * CELL); // 竖线 painter.drawLine(MARGIN, MARGIN i * CELL, MARGIN (BOARD_SIZE - 1) * CELL, MARGIN i * CELL); } for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (m_board[r][c] EMPTY) continue; painter.setBrush(m_board[r][c] BLACK ? Qt::black : Qt::white); painter.setPen(Qt::darkGray); painter.drawEllipse(MARGIN c * CELL - STONE_R, MARGIN r * CELL - STONE_R, STONE_R * 2, STONE_R * 2); } } }坐标换算规则很简单第 r 行第 c 列的交叉点像素坐标是 (MARGIN c * CELL, MARGIN r * CELL)棋子圆心落在交叉点上而 drawEllipse 接收的是外接矩形左上角所以要减去棋子半径。这里的 MARGIN、CELL、STONE_R 三个常量控制棋盘观感棋盘画偏了、棋子挤成一团改这三个值就行完全不用动逻辑代码。落子的交互逻辑在 mousePressEvent 里是一个标准的「坐标反算 状态检查 本地先行」流程void BoardWidget::mousePressEvent(QMouseEvent *event) { if (!m_myTurn) return; // 不是我的回合点击直接忽略 int col (event-pos().x() - MARGIN CELL / 2) / CELL; int row (event-pos().y() - MARGIN CELL / 2) / CELL; if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return; if (m_board[row][col] ! EMPTY) return; // 已落子位置不可重复下 m_board[row][col] m_role; // 本地先行界面零延迟 update(); // 请求重绘 emit moveMade(row, col); // 交给 NetworkClient 发出去 m_myTurn false; // 落子后立刻让出回合 }坐标反算的核心是加上 CELL / 2 再整除鼠标点下去的位置不可能精准落在交叉点上加半个格宽做四舍五入落在哪个格子的范围内都能正确归位。回合控制靠 m_myTurn 一个布尔量自己的落子动作完成后马上置 false收到对方消息后再由网络层置 true。这里的「本地先行」是联机游戏常用的手感优化——先更新界面再发网络消息玩家这边零延迟代价是如果发送失败需要回滚这一步。当前项目没做回滚你二次开发时可以在 emit 之后检查发送返回值失败就把 m_board[row][col] 复位并重新置回 m_myTurn。3.3 胜负判定五子连珠的检测算法胜负判定是五子棋唯一的算法核心。这个实现选了一个很聪明的切入点只检查最后一手落子。因为新出现的五连珠必然包含最后一颗棋子不需要全盘扫描每次落子后只检查经过该棋子的四条线即可。// common/logic.cpp —— 五子连珠检测 bool GameBoard::checkWin(int row, int col, int player) { // 四个方向向量水平、垂直、主对角线、副对角线 const int dirs[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d 0; d 4; d) { int count 1; // 当前落子本身算一颗 // 正方向沿方向向量累加 for (int step 1; step 5; step) { int r row dirs[d][0] * step; int c col dirs[d][1] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) break; if (m_board[r][c] ! player) break; count; } // 反方向沿方向向量的负方向累加 for (int step 1; step 5; step) { int r row - dirs[d][0] * step; int c col - dirs[d][1] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE) break; if (m_board[r][c] ! player) break; count; } if (count 5) return true; // 双向合计达到五颗即胜 } return false; }算法的关键在双向累加。只往一个方向数是最容易写错的版本——比如横线上左边有三颗、右边有一颗总数其实是五颗只数右边会误判为不赢。这个实现每个方向都向两端延伸直到遇到边界、空格或对方棋子才停。步长限制为 4 是因为从当前落子出发双向各数 4 步就能覆盖连五的所有可能性超出 4 步还数不到五颗就不必再数了。复杂度是 O(4×8)对一次落子来说就是几十次数组访问完全不是性能瓶颈。判定放在客户端还是服务端这个项目选了「两端各自判定」一方落子后本地先判一次消息发给对面对面收到后也判一次。因为两端的棋盘数组在同步状态下完全一致判定结果必然一致不需要服务端参与。这也依赖一个前提——消息不能丢、顺序不能乱这正是网络协议要兜底的事。你如果想改成服务端权威判定把 checkWin 挪到 server 进程里客户端只上报坐标、接收结果即可改动面集中在协议层加一条 RESULT 的触发条件。4. 编译运行与联机调试从 qmake 到双端握手4.1 环境准备与编译步骤准备环境只需要两样东西Qt 5.12 及以上版本自带 Qt Network 模块以及一个能跑 qmake 的命令行。Linux 下装完 qtbase 开发包后 qmake 就在 PATH 里Windows 上直接用 Qt Creator 打开 .pro 最省心。Qt Network 是默认捆绑模块不需要额外安装但如果编译报「找不到 QTcpServer 头文件」十有八九是工程文件里漏了 QT network。编译命令很简单在源码根目录执行qmake gomoku.pro make -j4如果根目录的 gomoku.pro 只是聚合文件也可以进到服务端和客户端目录分别构建cd server qmake server.pro make cd client qmake client.pro make构建产物是两个独立可执行文件gomoku_server 和 gomoku_client前者是无界面程序后者是带棋盘窗口的 GUI 程序。这里有个高频构建错误客户端怎么编译都报 undefined reference to QTcpSocket一看 .pro 文件是这样写的QT core gui widgets缺了 network 模块。补上这一行再重新跑 qmake 生成 Makefile 就正常了QT core gui widgets network CONFIG c17 TARGET gomoku_client TEMPLATE app SOURCES main.cpp mainwindow.cpp boardwidget.cpp networkclient.cpp HEADERS mainwindow.h boardwidget.h networkclient.h common/protocol.h注意改完 .pro 后要重新执行 qmake直接 make 不会自动重新生成 Makefile这个细节经常坑到人。如果服务端和客户端共用同一个 .pro 源文件列表建议按上面拆成两个子工程避免服务端链入一堆用不到的 GUI 依赖。4.2 双端启动与联机验证启动顺序有讲究先起服务端再起客户端。服务端指定监听端口客户端需要传服务器 IP 和端口两个参数# 机器 A启动服务端监听 8888 ./gomoku_server 8888 # 机器 A第一个客户端连本机 ./gomoku_client 127.0.0.1 8888 # 机器 B第二个客户端连机器 A 的局域网 IP ./gomoku_client 192.168.1.10 8888三个进程可以全跑在同一台机器上验证也可以用两台机器验证真实联机效果。验证标准看三条服务端日志出现两行「玩家接入」和一行「对局开始」两个客户端窗口各自显示黑棋或白棋的角色提示黑方点下一子白方窗口立刻出现黑子当一方凑齐五连两个窗口几乎同时弹出胜负框。如果前两步都正常说明连接、配对、转发、绘制这条链路已经完整打通。调试时可以在协议解码处加一行 qDebug把收到的 type、row、col 打出来对照着看消息走向。这里有个判断链路是否健康的技巧客户端落子后观察服务端是否打印了玩家接入之外的新日志如果服务端静默问题大概率在发送端如果服务端有转发、对面没反应问题大概率在接收端的解析或重绘。4.3 联调技巧单机双开模拟联机我推荐的第一步验证不是找两台机器而是在同一台机器上开两个客户端连本机服务端。单机双开能排除绝大部分网络环境干扰把变量压缩到协议和逻辑层面。操作上注意一点第二个客户端启动时窗口会重叠需要手动挪开或者给 main 函数加一个命令行参数控制窗口初始位置——这算是一个顺手的小改造加两行 setGeometry 就行。单机验证通过后再上双机联调。双机环境下最常遇到的差异是防火墙和监听地址服务端如果监听 127.0.0.1另一台机器永远连不进来监听 QHostAddress::Any 之后还要确认防火墙放行了对应端口。这两个问题在单机时根本暴露不出来所以我把双机测试作为提交代码之前的强制步骤。5. 避坑与排查联机五子棋最容易翻车的四个现场5.1 死活连不上监听地址与防火墙现象服务端跑起来了客户端一启动就报 connection refused或者一直卡在 connecting 状态。原因有两类一是服务端实际监听的地址不是你以为的那个二是防火墙拦截了端口。解决服务端用 QHostAddress::Any 监听不要写死 127.0.0.1否则局域网其他机器根本连不进来然后在服务端机器上临时放行 TCP 端口或者把程序加入防火墙白名单。验证方法也简单在客户端机器上 telnet 一下服务器 IP 加端口通不通一目了然。5.2 棋子串台、消息错位QDataStream 版本不一致现象联调时发现白方偶尔「吃掉」黑方的落子或者收到消息后行列坐标对不上棋盘上出现离谱的位置。原因encode 端和 decode 端用到的 QDataStream::setVersion 不同导致数值编码的字节序或格式不一致解析出来的 row、col 完全错乱。解决把 protocol.h 里两处 setVersion 统一建议直接定义一个宏比如 PROTOCOL_STREAM_VERSION编解码都引用它从源头杜绝两端版本分叉。这个坑我栽过版本号写死两处早晚会只改一处。5.3 界面一卡一卡不要在 GUI 线程里等网络现象落子之后界面僵住半秒才恢复移动窗口时棋盘重绘有明显延迟。原因有人在 NetworkClient 里用了 waitForReadyRead() 之类的阻塞等待把 GUI 线程堵住了。Qt 的 UI 刷新和事件循环共用主线程一旦主线程在等 socket重绘、点击、窗口拖动全部排队。解决全部改成信号槽驱动QTcpSocket 的 readyRead 信号到了就 readAll绝不主动等待网络层收到消息后通过信号把数据交回主窗口主线程只负责更新棋盘。非阻塞是 Qt 网络模块的默认能力用成阻塞纯属写法问题。5.4 连成六子才判赢判定只数了一个方向现象黑棋横着摆出五六颗连成一排却迟迟不弹胜利。原因checkWin 里某个方向只做了正方向计数没做反方向计数比如左边三颗、右边一颗总数五颗却被判不赢。解决按 3.3 节的双向累加写法每个方向向量都正反各数一遍。顺手给 checkWin 写几个边界用例——横向连五、纵向连五、斜向连五、断子、边界连五——覆盖完再联调。五子棋判定逻辑不复杂但不写用例就改出过只数单边的低级错误这是我在类似项目里的血泪教训。6. 二次开发加悔棋、加聊天、换协议三个改造方向这份源码的扩展点基本都围绕 protocol.h 展开。第一个改造方向是悔棋加一条 MSG_UNDO 消息类型客户端各自维护一个落子历史栈发起方发 UNDO对方收到后把自己棋盘最后一手回退并发 UNDO_ACK发起方收到 ACK 也回退再把回合标志还给发起方。服务端全程不用改因为薄转发不解析内容。第二个方向是加聊天。把编码函数扩展成支持 QString 参数QDataStream 对 QString 自动加长度前缀天然解决变长字段的拆包问题QByteArray encodeChat(const QString text) { QByteArray block; QDataStream out(block, QIODevice::WriteOnly); out.setVersion(PROTOCOL_STREAM_VERSION); out (quint8)MSG_CHAT text; // QDataStream 自带长度前缀 return block; }第三个方向是换 JSON 协议。当消息类型超过十种、字段越来越多时二进制协议的扩展性会变差用 QJsonDocument 序列化后每条消息变成可读的自描述结构调试直接打印 JSON 字符串。代价是报文体积变大、编解码略慢但对五子棋这种小流量场景完全没有压力。三条路我实际走过前两条共同的经验是先把协议层改完用 Qt Test 写编解码用例再动 UI 代码。建议你拿到这份源码后别急着翻界面先从 common/protocol.h 读懂四条消息跑通一局再挑一个方向下手。从那以后我每次改网络协议都会先打开 protocol.h 从头读一遍把消息类型、流版本、字段顺序对齐了才写业务代码。这个习惯帮我少踩了很多坑希望帮到你。本文还有配套的精品资源点击获取