简介这是一份面向C与Qt游戏开发学习者的网络联机五子棋完整项目源码适合已掌握C基础、希望理解实时对战与网络通信实现的中级开发者参考。项目采用客户端与服务端分离架构客户端界面基于Qt框架搭建网络部分使用Windows平台下的C socket实现服务端运行于Linux环境采用Linux socket网络编程整体以C/C完成支持公网对局。压缩包共28个文件约559KB包含6个cpp与4个h源文件承载核心逻辑3个ui界面文件与qrc资源文件、pro工程文件配合完成界面与构建另有png、jpg、ico等图片素材用于棋盘与菜单美化以及makefile与user配置便于编译运行。已有1400人学习下载。读者可从中获得一套可直接编译运行的联机对战范例理解客户端与服务端的通信流程、界面与逻辑分层组织方式并借鉴其目录结构与资源管理思路用于课程设计或小型联机游戏开发实践。1. 从一局断线的五子棋说起这个 C 联机项目到底能干什么周末跟朋友约着下五子棋结果对方网络一抖棋盘直接卡死重连回来发现棋子全没了——这种体验放在任何一款联机小游戏里都是致命的。这份 C 联机五子棋源码解决的正是「两个人隔着网络把一盘棋下完整」这件事客户端负责棋盘渲染和落子交互服务端负责房间管理、走子校验和状态同步中间靠一套自定义协议把每一步棋可靠地传过去。技术栈是 C 搭配 Qt用 Qt Widgets 画棋盘、用 QTcpSocket 做长连接属于那种「代码量不大但五脏俱全」的工程型练手项目。适合谁想从单机 Demo 跨到联机对战、想搞懂 TCP 粘包怎么处理、想拿一个能跑起来的 Qt 网络编程骨架去改自己玩法的开发者。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序把它拆开讲。2. 架构与目录客户端、服务端、协议三层怎么分工2.1 整体架构一个服务端扛房间多个客户端连进来这个项目的骨架是典型的 C/S 结构没有引入任何第三方网络库全靠 Qt 自带的 QTcpServer / QTcpSocket。服务端启动后监听一个固定端口每个客户端连上来就分配一个 socket 描述符服务端维护一张「连接表」表里记录每个连接对应的玩家 ID、所在房间号、当前是否轮到它走子。客户端这边则是「一个窗口 一个 socket」窗口负责画 15×15 的棋盘网格和黑白棋子socket 负责把落子坐标发出去、把对手的落子收回来。为什么不用 HTTP 短连接因为五子棋是强实时对战每一步都要立刻推给对方短连接每次握手开销大、延迟高长连接才是这个场景下的合格做法。常见做法是服务端用QTcpServer::newConnection信号接新连接用readyRead信号读数据客户端用QTcpSocket::connectToHost发起连接、用readyRead收数据。这套信号槽机制的好处是全程事件驱动不需要自己开线程轮询新手照着写也不容易把主线程卡死。房间管理这块源码里通常是「两两配对」的简化模型第一个连进来的玩家进等待队列第二个连进来就跟他组成一桌服务端给两人分配同一个房间号之后所有走子消息都按房间号转发。这种设计够用但不复杂二次开发想加观战、加多人房就得把「房间」抽象成一个独立对象这是后面改造章节要展开的点。2.2 目录结构源码包拆开长什么样拿到源码包目录大致是这么分的不同版本命名略有差异但职责划分基本一致FiveChess/ ├── server/ │ ├── main.cpp # 服务端入口启动监听 │ ├── server.h / server.cpp # QTcpServer 封装连接与房间管理 │ ├── clienthandler.h/.cpp # 单个连接的处理逻辑 │ └── protocol.h # 消息类型枚举与打包/解包 ├── client/ │ ├── main.cpp # 客户端入口 │ ├── mainwindow.h/.cpp # 主窗口棋盘绘制与交互 │ ├── chessboard.h/.cpp # 棋盘数据结构与胜负判定 │ └── networkclient.h/.cpp # QTcpSocket 封装 ├── common/ │ └── protocol.h # 客户端服务端共用的协议定义 └── FiveChess.pro # qmake 工程文件common/protocol.h被两边同时 include这是整个项目最该先看的地方——协议定义清楚了客户端和服务端才谈得上「对得上话」。chessboard单独抽出来是因为胜负判定逻辑横竖斜四个方向连五既要在客户端本地预判、又要在服务端做权威校验抽成公共模块避免两边写两份不一致的代码。2.3 协议设计定长头 变长体解决 TCP 粘包TCP 是字节流没有消息边界你发两次「落子」对面可能一次readyRead全收到也可能只收到半个。这个项目的处理方式是自定义应用层协议每条消息 4 字节长度头 1 字节消息类型 N 字节负载。读的时候先读 4 字节拿到总长度不够就等下一次readyRead够了再整条解析。// common/protocol.h —— 消息类型与打包 enum class MsgType : quint8 { Login 1, // 客户端上报昵称 MatchOk 2, // 服务端通知配对成功附带先手方 Move 3, // 落子负载为 x,y GameOver 4, // 胜负结果 Heartbeat 5 // 心跳保活 }; // 打包长度头(4) 类型(1) 负载 QByteArray packMessage(MsgType type, const QByteArray payload) { QByteArray block; QDataStream out(block, QIODevice::WriteOnly); out.setByteOrder(QDataStream::BigEndian); // 统一大端避免跨平台字节序翻车 out quint32(payload.size() 1); // 1 是类型字节 out static_castquint8(type); block.append(payload); return block; }这段代码的关键在setByteOrder(QDataStream::BigEndian)。血泪经验如果客户端在 x86 上按小端发、服务端在另一台机器上按默认字节序读长度头会解析成一个天文数字然后程序就卡在「等数据」里一动不动。统一大端是网络编程的通行做法别省这一行。解包侧对应写一个状态机维护一个QByteArray buffer每次readyRead把新数据 append 进去然后循环判断buffer.size() 4读出长度len再判断buffer.size() len够了就切出整条消息、解析类型和负载、从 buffer 里移除不够就 break 等下次。这个「缓冲区 循环切包」的写法是处理粘包半包的标准套路源码里clienthandler.cpp的onReadyRead就是干这个的。3. 跑起来从 qmake 编译到两人对下一局3.1 环境准备与编译需要 Qt 5.12 以上Qt 6 也能编但QDataStream部分 API 有变动见避坑章节编译器用 MinGW 或 MSVC 都行。用 Qt Creator 打开FiveChess.pro先构建 server 子项目、再构建 client 子项目或者直接在命令行# 在源码根目录假设 qmake 已在 PATH 中 qmake FiveChess.pro make -j4 # Windows MinGW 用 mingw32-make -j4编译产物是两个可执行文件server和client。如果只想快速验证先起服务端再起两个客户端实例即可不需要真的两台机器。3.2 启动服务端与客户端服务端默认监听 8888 端口源码里server.cpp构造函数中listen(QHostAddress::Any, 8888)启动后控制台会打印「Server listening on 8888」。客户端启动后弹出主窗口填服务器地址本机测试填127.0.0.1和昵称点连接。// client/networkclient.cpp —— 连接与登录 void NetworkClient::connectToServer(const QString host, quint16 port, const QString nick) { socket-connectToHost(host, port); if (!socket-waitForConnected(3000)) { // 3 秒超时别无限等 emit errorOccurred(连接超时检查服务端是否已启动); return; } QByteArray payload; QDataStream ds(payload, QIODevice::WriteOnly); ds nick; // 昵称作为登录负载 socket-write(packMessage(MsgType::Login, payload)); }waitForConnected(3000)这个超时参数别省。默认不传参是无限等待服务端没起的时候客户端界面会假死三秒以上用户以为程序崩了。给个 3 秒上限失败就弹提示体验立刻不一样。两个客户端都连上后服务端检测到连接数凑够两人发MatchOk给双方负载里带一个「谁先手」的标志。先手方棋盘可点后手方点击无效——这个「轮到谁」的状态必须由服务端权威判定客户端本地只做展示否则两边各点各的棋盘就乱了。3.3 一局棋的完整数据流落子时客户端做三件事本地先画上棋子乐观更新让手感跟手、把坐标打包发服务端、等服务端回执。服务端收到Move后先校验是不是这个房间的、是不是轮到他、这个格子是不是空的、有没有人已经赢了。校验通过才把这一步广播给房间内两个客户端同时更新轮次校验失败就回一条错误消息客户端收到后把刚才乐观画上的棋子撤掉。// server/clienthandler.cpp —— 落子校验核心 void ClientHandler::handleMove(quint8 x, quint8 y) { if (roomId 0) { sendError(尚未匹配到对手); return; } if (currentTurn ! playerId) { sendError(还没轮到你); return; } if (board.at(x, y) ! Empty) { sendError(该位置已有棋子); return; } board.place(x, y, myColor); broadcastMove(x, y, myColor); // 转发给房间内两人 if (board.checkWin(x, y, myColor)) { // 以落子点为中心四方向判定 broadcastGameOver(myColor); roomReset(roomId); } else { currentTurn opponentOf(playerId); // 切换轮次 } }checkWin的写法值得单独说不需要扫全盘只以刚落的这颗子为中心向横、竖、两条斜线四个方向各数一遍连续同色棋子任一方向达到 5 就赢。这样单步判定是 O(1) 量级的常数操作比每次全盘扫描优雅得多。源码里chessboard.cpp的checkWin就是四个for循环加计数逻辑直白新手也能一眼看懂。4. 核心模块拆解棋盘绘制、胜负判定与连接管理4.1 棋盘绘制paintEvent 里画网格和棋子Qt 里画自定义棋盘标准做法是重写QWidget::paintEvent。棋盘 15×15每个格子边长设为cellSize左上角留边距margin网格线用drawLine循环画棋子用drawEllipse填充黑白。// client/mainwindow.cpp —— 棋盘绘制 void MainWindow::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); // 抗锯齿不然圆棋子有毛边 const int margin 30, cell 36; // 画 15 条横线和 15 条竖线 for (int i 0; i 15; i) { p.drawLine(margin, margin i * cell, margin 14 * cell, margin i * cell); p.drawLine(margin i * cell, margin, margin i * cell, margin 14 * cell); } // 遍历棋盘数据画棋子 for (int x 0; x 15; x) for (int y 0; y 15; y) { if (board.at(x, y) Empty) continue; QColor c (board.at(x, y) Black) ? Qt::black : Qt::white; p.setBrush(c); p.drawEllipse(QPoint(margin x * cell, margin y * cell), cell / 2 - 2, cell / 2 - 2); } }setRenderHint(QPainter::Antialiasing)这行是玄学级的存在不加棋子边缘锯齿明显整个界面廉价感拉满加了观感立刻上一个档次代价几乎可以忽略。cell / 2 - 2里的-2是让棋子比格子略小一圈留出网格线不然相邻棋子会糊在一起。鼠标点击转棋盘坐标是反过来的(event-pos().x() - margin cell/2) / cell加cell/2是为了四舍五入到最近的交叉点而不是简单截断——不加的话点击格子右半边会落到下一格手感很怪。4.2 胜负判定以落子点为中心的四方向计数前面提过checkWin只扫落子点周围这里把四个方向的写法展开。核心是一个方向向量数组每个方向正反各数一次加起来等于 5 即胜。// client/chessboard.cpp —— 四方向连五判定 bool ChessBoard::checkWin(int x, int y, Piece color) { static const int dirs[4][2] {{1,0},{0,1},{1,1},{1,-1}}; // 横、竖、主斜、副斜 for (auto d : dirs) { int count 1; // 当前这颗子算 1 for (int sign -1; sign 1; sign 2) { // 正反两个方向 int nx x d[0] * sign, ny y d[1] * sign; while (inBoard(nx, ny) at(nx, ny) color) { count; nx d[0] * sign; ny d[1] * sign; } } if (count 5) return true; } return false; }inBoard做边界检查防止数组越界——这是最容易翻车的地方斜向计数时ny可能变成负数直接at(nx, ny)就是未定义行为程序可能当场崩也可能悄悄读错数据。dirs里{1,-1}是副对角线方向sign循环让每个方向正反都数一遍count从 1 起算是因为落子点本身已经占了一格。这套写法比「遍历所有五连窗口」简洁得多也更好调试。4.3 连接管理断线检测与心跳联机游戏最怕「对面已经掉线这边还在傻等」。源码里用了一个简单的心跳机制客户端每隔 5 秒发一条Heartbeat服务端收到就更新该连接的「最后活跃时间」另起一个QTimer每 10 秒扫一遍连接表超过 15 秒没心跳的就判定掉线通知房间内另一方「对手已断开」。// server/server.cpp —— 心跳超时扫描 void Server::checkHeartbeat() { QDateTime now QDateTime::currentDateTime(); for (auto it clients.begin(); it ! clients.end(); ) { if (it.value()-lastActive.secsTo(now) 15) { notifyOpponent(it.value()-roomId, 对手已断开); it.value()-socket-disconnectFromHost(); it clients.erase(it); // erase 返回下一个迭代器别用 it } else { it; } } }it clients.erase(it)这个写法是 C 容器删除元素的标准姿势。用clients.erase(it); it;会在 erase 之后对已失效的迭代器自增轻则跳过元素重则崩溃——这个坑我见过不止一次。心跳间隔和超时阈值5 秒 / 15 秒是经验值局域网够用公网弱网环境可以放宽到 10 秒 / 30 秒代价是掉线感知变慢。5. 避坑与排查联机调试最容易翻车的五个地方5.1 现象客户端连上了但收不到任何消息原因通常是协议头字节序不一致或者服务端readyRead里没做循环切包一次只处理了一条消息剩下的留在缓冲区里没人管。解决先确认两边QDataStream都设了BigEndian再检查读数据的地方是不是while循环而不是if。调试时在切包处打印buffer.size()和解析出的len一眼就能看出是长度算错还是数据没到。5.2 现象Qt 6 下编译报错QDataStream相关Qt 6 把QDataStream的默认版本行为改了部分operator对QByteArray的处理和 Qt 5 不一致导致长度头解析错位。解决在读写前显式ds.setVersion(QDataStream::Qt_5_15)把序列化格式钉死在旧版本两边都设问题消失。这是升级 Qt 版本时最常见的兼容性坑。5.3 现象两人同时落子棋盘出现两颗同色棋子原因是客户端「乐观更新」画了棋子但服务端校验失败后没有回滚机制或者回滚消息丢了。解决服务端校验失败必须回一条带坐标的MoveRejected客户端收到后把该坐标重置为Empty并update()重绘。更稳的做法是客户端不做乐观更新等服务端广播回来再画代价是手感略延迟——局域网下几乎无感公网下要权衡。5.4 现象程序退出时崩溃报QSocketNotifier相关原因是窗口关闭时 socket 还在收数据信号槽连到了一个已经析构的对象上。解决在MainWindow析构函数里先socket-disconnectFromHost()并socket-deleteLater()或者用QObject::disconnect断开所有连接再销毁。Qt 的对象树机制能管住大部分生命周期但 socket 这种跨线程/跨事件循环的对象要手动收尾。5.5 现象本机能连换台机器就连不上九成是防火墙拦了 8888 端口或者服务端listen绑的是QHostAddress::LocalHost而不是Any。解决确认listen(QHostAddress::Any, 8888)在目标机器上放行该端口用telnet 服务端IP 8888先验证端口通不通通了再查应用层。这个排查顺序能省掉大量「以为是代码问题其实是网络问题」的时间。6. 二次开发把「两人房」改成「房间列表 观战」的改造路径想在这个骨架上加功能第一步不是写代码是先把「房间」从隐式的roomId整数升级成独立对象。现在源码里房间信息散落在各个ClientHandler的成员变量里加观战、加多桌并行会很痛苦。我的做法是新建Room类持有playerA、playerB、spectators列表和ChessBoard实例Server维护QMapint, Room*ClientHandler只负责收发业务逻辑全部下沉到Room。// server/room.h —— 房间对象化后的核心接口 class Room : public QObject { Q_OBJECT public: void joinAsPlayer(ClientHandler* h); // 前两个进玩家位 void joinAsSpectator(ClientHandler* h); // 之后的进观战位 void handleMove(ClientHandler* from, int x, int y); void broadcast(const QByteArray msg, ClientHandler* except nullptr); private: ClientHandler* playerA nullptr; ClientHandler* playerB nullptr; QListClientHandler* spectators; ChessBoard board; int currentTurn 0; };改造后的验证方法很直接开三个客户端前两个自动配对进对局第三个收到「观战中」状态且棋盘只读对局中任一方掉线观战方收到通知、房间保留 30 秒等待重连。这套「房间对象 角色区分」的结构加计时器、加悔棋、加聊天都是往Room里挂新方法不用动网络层。另一个高频改造点是协议扩展。现在MsgType是枚举加新消息类型时记得同步改客户端和服务端的switch分支并且给未知类型留一个default分支打日志而不是直接崩——线上排查时这条日志能救命。协议版本号也建议在Login负载里带上服务端发现版本不匹配就拒绝连接并提示升级比让用户对着黑屏猜原因强得多。从那以后我每次改这类联机项目都强制先跑一遍「三客户端 断线重连」的冒烟测试再提交因为联机 bug 的复现成本远高于单机早发现一步省下的调试时间是以小时计的。希望这份拆解能帮你把这个五子棋骨架跑起来再改成你自己想要的样子。本文还有配套的精品资源点击获取