尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

C++网络联机五子棋源码拆解:Qt与TCP实战解析

发布时间:2026/9/29 7:57:47

资讯中心
01
ARTICLE

C++网络联机五子棋源码拆解:Qt与TCP实战解析

C++网络联机五子棋源码拆解:Qt与TCP实战解析
简介面向C游戏开发与网络编程学习者这份源码完整实现了可实时联机的五子棋小游戏。项目采用客户端/服务端架构客户端基于QT框架完成界面与交互网络层分别使用Windows和Linux平台下的socket编程支持公网对局服务端运行于Linux环境便于对比不同系统下的网络实现差异。压缩包共28个文件包含6个cpp核心逻辑、4个h头文件、3个ui界面设计、9个png界面素材以及pro工程配置文件整体大小仅559KB结构简明适合边读源码边理解QT界面搭建与跨平台通信的关键环节。目前该资源已有1399人学习下载适合具备基础C知识、希望进阶掌握TCP通信与GUI整合的开发者参考同时可直接编译运行并在此基础上扩展功能。1. 网络联机五子棋的源码拆解先分清客户端与服务端再动手拿到这份“网络联机五子棋小游戏源码(C)”之后我第一个动作不是打开 .pro 文件编译而是先把目录结构过一遍。原因很简单联机五子棋本质上不是一个程序而是两个程序——一个负责等别人连进来的服务端一个负责主动去连别人的客户端。如果你不先分清这两份工程后面编译、运行、联调时很容易翻车甚至出现“明明两边都启动了却谁也连不上谁”的奇怪情况。这套源码对想入门 C 游戏开发、又想把 socket 通信和 QT 界面串起来的人非常合适它把 QPainter 绘图、鼠标交互、TCP 消息收发、胜负判定这些知识点全部揉进了一个完整的项目里拿来拆解、改造、做课程设计都很顺手。2. 把棋盘画出来QPainter 绘图流程与坐标换算2.1 棋盘绘制从网格线到棋子落点的坐标换算五子棋的棋盘是 15×15 的网格但这不代表代码里要画 15 条横线和 15 条竖线就完事了。真正麻烦的是坐标换算鼠标在窗口上的像素坐标怎么对应到网格的交叉点再怎么落子。常见做法是让棋盘区域用一个自定义的 QWidget 子类承载重写它的 paintEvent 事件所有绘制逻辑都集中在这里。// BoardWidget.h 关键成员 class BoardWidget : public QWidget { Q_OBJECT public: static const int BOARD_SIZE 15; // 棋盘 15x15 交叉点 static const int MARGIN 30; // 棋盘边缘留白像素 static const int CELL_SIZE 40; // 每个格子边长像素 private: QVectorQPoint m_stones; // 已落子的坐标棋盘索引 protected: void paintEvent(QPaintEvent *) override; };棋盘绘制的核心在 paintEvent 里先用 QPainter 画网格线再根据 m_stones 里的索引坐标画黑白棋子。这里有个容易被忽略的点窗口尺寸和棋盘尺寸是两回事。为了不让窗口拉伸时棋盘变形通常的做法是固定棋盘区域的尺寸或者按比例缩放——这套源码用的是固定尺寸方案BoardWidget 的最小尺寸就是 15×402×30 660 像素。void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 画背景色 painter.fillRect(rect(), QColor(219, 177, 110)); // 画网格线横线 15 条竖线 15 条 for (int i 0; i BOARD_SIZE; i) { int pos MARGIN i * CELL_SIZE; painter.drawLine(MARGIN, pos, MARGIN (BOARD_SIZE - 1) * CELL_SIZE, pos); painter.drawLine(pos, MARGIN, pos, MARGIN (BOARD_SIZE - 1) * CELL_SIZE); } // 画已落棋子遍历 m_stones for (const QPoint stone : m_stones) { int cx MARGIN stone.x() * CELL_SIZE; int cy MARGIN stone.y() * CELL_SIZE; painter.setBrush(stone.color() 0 ? Qt::black : Qt::white); painter.setPen(QPen(Qt::gray, 1)); painter.drawEllipse(QPoint(cx, cy), CELL_SIZE / 2 - 2, CELL_SIZE / 2 - 2); } }这段代码里有两个细节值得注意一是 setRenderHint 抗锯齿如果不打开棋子边缘会呈现明显的锯齿二是 drawEllipse 的圆心坐标是基于交叉点像素坐标算出来的而不是直接用鼠标点击的像素坐标。换句话说棋子永远画在网格交叉点上不会出现“棋子悬在两格之间”的尴尬画面。参数上CELL_SIZE 取 40 是因为这个值在 1080P 屏幕下看起来不算挤如果窗口偏小可以改成 30 或 35但要注意同时调整 MARGIN保证棋盘整体居中。2.2 鼠标交互点击判定、落子反馈与悔棋的数据结构绘制只是把已有数据画出来真正的交互逻辑在 mousePressEvent 里。当用户点击棋盘时程序要做两件事把像素坐标换算成棋盘索引然后判断该位置是否已有棋子。换算公式不复杂index (pixel - MARGIN) / CELL_SIZE但这里有个考验细节的边界问题——点击点落在两个交叉点正中间时除以 CELL_SIZE 会得到带小数的结果要四舍五入取整。void BoardWidget::mousePressEvent(QMouseEvent *event) { // 像素坐标转棋盘索引先减 MARGIN再除以格子边长四舍五入 int boardX (event-pos().x() - MARGIN CELL_SIZE / 2) / CELL_SIZE; int boardY (event-pos().y() - MARGIN CELL_SIZE / 2) / CELL_SIZE; // 检查范围超出棋盘边界直接忽略 if (boardX 0 || boardX BOARD_SIZE || boardY 0 || boardY BOARD_SIZE) return; // 检查是否已有棋子避免覆盖落子 for (const QPoint stone : m_stones) { if (stone.x() boardX stone.y() boardY) return; } // 合法则落子并触发重绘 m_stones.append(QPoint(boardX, boardY)); update(); // 关键调用 update 才会触发 paintEvent 重新执行 }这里最容易踩的坑是忘记 update()。有初学者直接在 mousePressEvent 里执行业务逻辑后不重绘屏幕上一片死寂还以为是绘图函数写错了——其实是没通知 Qt 重新绘制。另外一个点是索引换算的偏移坐标减去 MARGIN 之后先加上 CELL_SIZE / 2 再整除本质是四舍五入而不是直接 floor。如果是直接除法点击靠近交叉点左上区域时会落到错误的格子上落子手感会明显偏移这也是很多新手说“鼠标点击位置对不上”的根源。悔棋的数据结构在这套源码里用了最简单的方案一个 QVector 存储落子历史悔棋只做一件事——弹出最后一个元素然后 update()。如果要做多步悔棋或者支持“悔棋后重新落子”的场景这个方案就不够用了得换成真正的历史栈。但对基础版联机五子棋来说这个设计够用且直观。2.3 胜负判定四方向扫描的循环写法五子棋的胜负判定看着简单其实是个很典型的算法题从刚落下的棋子出发沿横、竖、左斜、右斜四个方向分别向两端延伸统计连续同色棋子的数量只要任意方向上总数达到 5 就判定胜利。写代码时最容易出错的不是逻辑本身而是边界处理和方向向量的定义。bool checkWin(const QVectorQPoint stones, int startX, int startY, int color) { // 四个方向右、下、右下、左下 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 nx startX dirs[d][0] * step; int ny startY dirs[d][1] * step; if (!hasStone(stones, nx, ny, color)) break; count; } // 向反方向延伸 for (int step 1; step 5; step) { int nx startX - dirs[d][0] * step; int ny startY - dirs[d][1] * step; if (!hasStone(stones, nx, ny, color)) break; count; } if (count 5) return true; } return false; }这段写法有个好处hasStone 函数内部统一处理了棋盘越界检查所以在 checkWin 里完全不需要关心 nx、ny 是否越界代码干净很多。如果你拿到手的源码里胜负判定是一大坨 for 循环嵌套性能不一定差但可读性一定差——这个函数是整份源码里最适合做重构练习的地方。另外要留意的是胜负判定的触发时机联机模式下本地落子只判断自己的胜局对方的落子由服务端转发后在客户端触发同样的判定两边要保证用的是同一套判定逻辑否则会出现“本地显示赢了、对方却还在下”的分裂局面。3. 网络联机模块TCP Socket 通信协议与消息编码3.1 选择 TCP 而非 UDP为什么联机对战必须可靠传输五子棋对战的每个落子消息只有几十个字节按理说 UDP 也够用但这个场景的核心问题不是带宽而是消息到达顺序。一局棋里每一步都是强依赖关系你只有先收到对方上一步落子才能正确处理自己下一步。UDP 不保证顺序也不保证送达一旦发生丢包双方棋盘状态就会永久错位根本无法恢复。所以这套源码采用的是 TCP而不是 UDP。TCP 的粘包问题确实需要处理但总比棋盘错乱的“黑匣子”状态好十倍。也许有人会反驳五子棋可以不用增量同步每次全量同步整个棋盘状态这样 UDP 丢包后可以重发全量数据自愈。这里的设计者没有走那条路而是老老实实用 TCP这和我见过的多数联机小游戏的做法一致。除非你有明确的心跳快照设计需求否则 TCP 简单消息协议是性价比最高的组合。3.2 消息协议设计固定包头、序列化与粘包处理TCP 是流式协议没有消息边界所以发送方和接收方必须自定协议。这套源码采用的设计是“固定长度包头 可变长度正文”安全又简单。包头固定 8 字节其中 4 字节存消息类型另外 4 字节存消息体长度实现起来很直接。// 协议定义消息类型枚举 enum MsgType { MSG_LOGIN 1, // 客户端登录携带昵称 MSG_MOVE 2, // 落子消息携带 x, y MSG_READY 3, // 客户端准备开始 MSG_WIN 4, // 胜负结果通知 MSG_CHAT 5 // 聊天消息可选 }; // 消息编码函数传入消息类型和 JSON 字符串返回完整字节流 QByteArray encodeMessage(int type, const QJsonObject payload) { QJsonDocument doc(payload); QByteArray body doc.toJson(QJsonDocument::Compact); QByteArray head; // 使用 QDataStream 写 4 字节类型 4 字节长度统一网络字节序 QDataStream stream(head, QIODevice::WriteOnly); stream.setByteOrder(QDataStream::BigEndian); stream type (int)body.size(); return head.append(body); }正文用了 JSON 而不是自定义二进制结构体好处是后续加字段不用改协议版本坏处是每种消息都要做序列化和反序列化的开销。五子棋每秒最多几次交互完全不需要在意那几 KB 的数据量。粘包处理是接收端的事。常见做法是在 socket 的 readyRead 信号中用 QByteArray 拼接缓冲先判断缓冲是否足够 8 字节的包头再按照包头里的长度字段判断是否收到完整消息体。// 接收端缓冲处理解决粘包和半包问题 void NetworkClient::onReadyRead() { m_buffer.append(m_socket-readAll()); while (m_buffer.size() 8) { // 先读包头取得长度字段 QDataStream stream(m_buffer); stream.setByteOrder(QDataStream::BigEndian); int type; int bodyLen; stream type bodyLen; if (m_buffer.size() 8 bodyLen) { return; // 半包情况还没收完等下次 readyRead } m_buffer.remove(0, 8); QByteArray body m_buffer.left(bodyLen); m_buffer.remove(0, bodyLen); // 解析 JSON 后分发给业务层 handleMessage(type, QJsonDocument::fromJson(body).object()); } }这套代码里的关键参数是 BigEndian。为什么非要用大端字节序因为不同平台的整型默认字节序不一致自己定协议时如果不统一字节序客户端在 Windows、服务端在 Linux 上就会出现解析错位。QDataStream 默认用的是大端所以这里保持默认即可。3.3 服务端与客户端封装QTcpServer 监听与信号槽接线服务端用的是 QTcpServer 类。它在 listen 之后有新的连接进来会触发 newConnection 信号服务端需要在这个信号的处理里调用 nextPendingConnection 获取对应的 QTcpSocket 对象保存起来供后续通信使用。// 服务端核心逻辑 void GameServer::onNewConnection() { QTcpSocket *client m_server-nextPendingConnection(); m_clients.append(client); // 每个客户端 socket 的 readyRead 都连接同一个槽 connect(client, QTcpSocket::readyRead, this, GameServer::onClientData); connect(client, QTcpSocket::disconnected, this, GameServer::onClientDisconnected); // 收到连接后给客户端回一个欢迎消息 QJsonObject payload; payload[playerId] m_clients.size(); client-write(encodeMessage(MSG_LOGIN, payload)); }客户端这边更简单用 QTcpSocket 的 connectToHost 指定 IP 和端口连接成功后通过 write 发送数据。需要特别提醒的是connectToHost 是异步操作不要连着调用多个 write 再立即 close否则数据可能还没发出去连接就断了。正确的做法是在 connected 信号里再发登录消息这样能保证连接已建立。服务端不支持中断重连。代码里 m_clients 是 QVector如果其中一个客户端掉线那个位置会残留一个空对象如果不做清理后续转发消息给所有人时会访问到非法指针。服务端在 disconnected 信号里把对应 socket 从容器中移除是必修课这块源码肯定做了但你在改造扩展时千万别把这段删除。4. 联机对战的避坑记录从黑屏、粘包到断线误判4.1 棋子画上去了但窗口一片黑update 和 paintEvent 的触发机制被忽略现象调用了 m_stones.append 之后界面上没有任何新棋子出现。 原因没有调用 update()或者 update() 被注释掉了。Qt 的 widget 不会在数据变化时自动重绘必须显式请求。还有一个常见原因在 paintEvent 里查了 m_stones但 m_stones 是在另一个线程里被修改的UI 线程不知道数据变了。 解决落子操作末尾强制调用 update()如果涉及多线程数据操作不要直接改容器通过信号槽把数据抛回 UI 线程再修改。4.2 网络消息粘包两条消息挤在一起后第二条解析失败现象连续发送两条落子消息后接收端经常只处理了第一条第二条丢失或报 JSON 解析错误。 原因TCP 是流协议两条消息可能被合并成一个数据包发出接收端如果默认“一次 readAll 就是一条消息”就会把两条消息同时解析成一条导致 JSON 解析失败。 解决接收端必须用字节缓冲累积数据先解析定长包头再按长度取消息体。上面 3.2 节的代码就是标准解法这也是这份源码里最值得借鉴的部分。4.3 棋子错位客户端和服务端的棋盘索引不是从零开始现象客户端点击棋盘左上角落子服务端收到坐标却是 (1,1)棋盘边界多了一格空位。 原因服务端和客户端的坐标基准不一致。通常是客户端把像素坐标直接发送出去服务端又做了一次坐标转换双重换算导致偏移。 解决只发送棋盘索引不发送像素坐标。所有坐标转换都收拢到客户端绘制层服务端和客户端通信只谈索引这样哪边的棋盘规格变了都不会影响通信格式。4.4 断线误判socket 显示 connected 但对方已经关闭程序现象一方直接关掉窗口另一方过了十几秒才收到 disconnected 信号。 原因TCP 的断开检测依赖底层保活机制默认探测周期很长程序非正常退出时甚至可能不发 FIN 包。 解决增加应用层心跳每 3 秒发一条 ping 消息连续 3 次没收到 pong 就主动断开并提示玩家。这是联机小游戏里最常见的保活方案源码里大概率已经加上了你在改造时不要删掉心跳逻辑。4.5 界面卡死在槽函数里循环等待数据现象点击“联机”按钮后整个窗口转圈鼠标移到窗口上变成沙滩球。 原因在按钮点击的槽函数里写了 while (!m_socket-waitForReadyRead(5000)) 之类的阻塞代码。waitForReadyRead 会阻塞当前线程而 QT 的 UI 事件循环在没有用户代码时是自动执行的一旦阻塞就再也收不到任何消息。 解决改成真正的异步模式连接 readyRead 信号做数据接收不要在任何槽函数里使用 waitFor* 系列阻塞 API。这也是 QT 网络编程最底层的思维转变——不要用同步的思路写 Qt 的 socket 代码。5. 把这套代码跑起来Qt 工程配置与联调顺序5.1 用 Qt Creator 打开工程qmake 与 .pro 文件里的关键配置拿到源码后第一件事不是直接编译而是检查 .pro 文件里的模块配置。网络五子棋这款程序依赖 Qt 的 Widgets 模块做界面、Qt Network 模块做 socket 通信两个缺一不可。如果 .pro 里漏了 network编译时 QTcpSocket 相关代码会直接报“未定义类型”这个错误出现时新手容易怀疑代码写错了实际上只是模块没链接。# 关键配置项 QT core gui network greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET GobangClient # 客户端工程目标名 TEMPLATE app CONFIG c11 SOURCES \ main.cpp \ MainWindow.cpp \ BoardWidget.cpp \ NetworkClient.cpp HEADERS \ MainWindow.h \ BoardWidget.h \ NetworkClient.h注意服务端工程一般和客户端工程是分开的因为它们的入口类不同。源码包的目录里通常会区分 client 和 server 两个子目录各有各自的 .pro 文件。用 Qt Creator 打开时先打开服务端的 .pro构建后运行再打开客户端的 .pro构建后运行。如果你拿到的源码只有一个工程文件那说明服务端逻辑被整合进了客户端的 MainWindow 里这种写法做本地双窗口调试可以但不利于真正部署到两台机器上。5.2 先启服务端再启客户端联调顺序与常见失败信号运行顺序有个不成文的约定先启动服务端再启动客户端。如果你反着来客户端点击“连接”时服务端还没监听端口connectToHost 会立刻报 connection refused 错误。更麻烦的是QT 的 connectToHost 是异步的错误信息不会立即弹出来而是在若干毫秒后通过 errorOccurred 信号通知你初次接触时很容易忽略这个时序问题。正确的联调顺序是运行服务端程序确认窗口标题栏显示“监听中端口 8888”运行客户端程序在 IP 输入框填写 127.0.0.1端口填写 8888点击“连接”客户端状态栏变成“已连接”双方各点一次“准备”棋盘进入可落子状态客户端落子服务端棋盘同步出现棋子反之亦然调试过程中如果客户端提示“连接失败”不要急着改代码先在命令行用 netstat 或 lsof 确认端口是否真的在监听# Windows 下查看端口监听状态 netstat -ano | findstr 8888 # Linux/macOS 下查看端口监听状态 lsof -i :8888如果端口没监听多半是服务端 listen 失败——最常见原因是该端口被其他程序占用或者服务端绑定地址写死成了 127.0.0.1导致另一台机器无法访问。5.3 用日志和抓包验证消息收发不要靠肉眼判断谁对谁错联机槽道调试到怀疑人生时强劲的劝退心态就会出现这时靠界面上的棋子对不对来判断问题在哪里是不靠谱的因为界面渲染和网络收发是两层肉眼只能看到渲染层的结果网络层到底传输了什么其实是黑匣子。两种验证手段最直接一是在收发函数里加 qDebug 日志二是用 Wireshark 抓包看真实的数据流。// 在 onReadyRead 里加日志确认收到的原始字节 void NetworkClient::onReadyRead() { QByteArray data m_socket-readAll(); qDebug() [NET] recv data.size() bytes: data.toHex(); m_buffer.append(data); // ...后续解析逻辑 } // 发送端加日志确认消息内容和长度 void GameClient::sendMove(int x, int y) { QJsonObject payload; payload[x] x; payload[y] y; QByteArray msg encodeMessage(MSG_MOVE, payload); qDebug() [NET] send msg.size() bytes: msg.toHex(); m_socket-write(msg); }通过 toHex 能看到完整的数据内容比在界面上用“你觉得棋子有没有落下来”去猜不知道效率高多少倍。如果你抓到的数据里包头长度是负数或者 JSON 解析出来是空的那基本可以确定协议解析处出了问题而不是“网络不稳定”这种玄学原因。6. 把单机扩展成局域网联机IP 绑定与断线重建6.1 从 127.0.0.1 到局域网 IP服务端监听地址怎么选把源码在自己的电脑上跑通 127.0.0.1 联机后下一步自然是找两台机器对战。这时会遇到一个非常现实的配置问题服务端在 listen 的时候到底监听哪个地址如果你绑定的是 127.0.0.1那就只有本机能连如果你想开放给整个局域网必须监听 0.0.0.0或者在 QTcpServer 的 listen 里传 QHostAddress::Any。// 正确姿势监听所有网卡让局域网内其他机器都能连 bool ok m_server-listen(QHostAddress::Any, 8888); // 或者指定某个具体网卡 IP例如 192.168.1.100 bool ok m_server-listen(QHostAddress(192.168.1.100), 8888);客户端连接时IP 地址要填服务端所在机器的局域网 IP而不是 127.0.0.1。很多人在这一步翻车服务端监听 Any 开好了客户端那边 IP 填的是自己机器的 IP结果当然连不上——你要填的是服务端那台机器的 IP不是自己的 IP。还有一个很容易被忽略的点Windows 防火墙。默认情况下Windows 会拦截外部机器访问未授权程序的端口。第一次运行服务端时系统会弹出防火墙授权对话框如果点掉了“取消”后续所有外部连接都会超时。这时去控制面板的防火墙规则里手动放行该程序或端口 8888 即可。6.2 增加断线重连与认输按钮两个低成本改进基础版拿到手能玩之后如果想要功能更完整我建议优先加两个东西断线重连和认输/悔棋请求。断线重连的逻辑不复杂客户端在 errorOccurred 或 disconnected 信号里启动一个 QTimer每 2 秒尝试一次 connectToHost直到连接成功或者用户主动取消。注意要设置一个最大重连次数否则服务端一直没启动客户端就会无限重试界面卡死和消息堆积都会出现。认输按钮就更简单了点击后发送 MSG_WIN 消息Payload 里注明认输方。服务端收到后广播给双方游戏结束。悔棋在联机模式下比较麻烦因为涉及双方同意需要多一个 MSG_REGRET 和 MSG_REGRET_ACK 消息类型服务端只转发不做决策由对方决定是否同意。6.3 一个防止联机状态错乱的检查习惯最后分享一个我自己吃了大亏之后养成的检查习惯每次启动联机前强制检查双方的棋盘状态是否一致——开局前先打印一次棋盘索引总数每走一步打印一次步数序列。如果双方的步数序列前 10 步完全一致后面才开始不用继续对基本可以断定网络模块没有丢步。从那以后我每次拿到联机类源码都会先做一遍这样的自检而不是等到玩家打了两局才发现步数对不上。这套源码本身不大但这种排查思路能帮你少写一百遍“为什么我的棋子会消失”。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。