news 2026/11/2 4:42:15

C++ Qt联机五子棋:TCP分帧与服务端裁决实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ Qt联机五子棋:TCP分帧与服务端裁决实战

简介:一份面向C++与Qt游戏开发学习者的网络联机五子棋项目源码,定位于客户端与服务端双端实战:客户端基于Qt框架搭建界面,并通过Windows平台C++ socket完成网络通信;服务端运行于Linux环境,采用Linux socket编程实现房间与对局数据处理,项目整体基于C/C++编写,支持公网联机对弈。压缩包共28个文件,约559KB,包含6个cpp源文件、4个头文件、3个ui界面文件,以及png/jpg图片、ico图标、qrc资源、pro工程文件和makefile等,覆盖棋盘绘制、落子判定、胜负判断、联机等待、界面布局、网络连接与工程构建等模块,客户端、服务端与资源文件分区明确。已有1400人学习下载,适合作为Qt界面设计、Socket通信和联机游戏逻辑的综合练习素材。读者可从中梳理双端通信流程、对局状态管理与界面资源组织方式,并参考工程配置完成本地编译调试,对理解C++网络编程与Qt游戏项目结构有较直接的帮助。

1. 从两台机器连不上说起:这套联机五子棋真正要解决什么

做联机五子棋最难受的不是棋盘画不出来,而是两边都以为自己在下一盘棋——一方落子后对面没收到,或者收到了却已顺序错乱,界面上还显示一切正常。这份网络联机五子棋小游戏源码(C++)把问题拆成清清楚楚的两块:Qt Widgets 负责棋盘绘制、鼠标交互和走子动画,QTcpSocket / QTcpServer 负责把每一次落子变成一条带长度前缀、可被服务端校验的消息,回合归属和胜负判定全部交给服务端裁决。它适合三类人:要交 C++ Qt 游戏开发课程设计的同学,想把单机 demo 改成“两台机器真人对下”的毕设项目,以及界面已经会画、但没写过网络同步的开发者拿来对照结构。下面按需求、选型、设计、实现、排查、验收的顺序,把源码里的模块划分和关键代码逐段拆开。

2. 通信模型与协议设计:服务端裁决、长度前缀加 JSON 的两层结构

联机游戏的骨架是通信模型,棋盘逻辑只占其中一小部分。选错通信模型,后面每加一个功能都要打补丁;选对了,加悔棋、加观战都只是多一条消息类型的事。

2.1 客户端与服务端的职责边界

三选一:P2P 对等连接、主机兼任服务端、独立服务端进程。这个项目采用独立服务端。理由很直接:五子棋必须有唯一依据来判断“这一手是谁下的、是否合法、谁赢了”,如果两边各存一份棋盘并且各自判胜负,任何一次网络抖动都会让双方状态分叉,后面几乎无法修复。独立服务端把所有状态集中在一处管理,客户端退化成“渲染 + 输入”,状态不一致的概率趋近于零。

具体分工是:

  • 客户端只做四件事:渲染棋盘、把鼠标点击换算成坐标、把“请求落子”发出去、收到服务端广播后更新界面。
  • 服务端持有棋盘实例、当前回合、房间状态、双方连接对象,负责合法性校验、判胜、广播结果。
  • 任何一方都不在本地“先落子再上报”。这一点看起来违反直觉——本地先落子界面响应更快——但它会让“服务端拒绝”时界面需要回滚,回滚逻辑写不好就是界面显示有一手棋、服务端记录里没有。在这类项目里,我会选择等广播回来再画。

这条边界定下来之后,协议里就不需要“悔棋同步”“状态校正”这类补偿消息,整个消息集合能压到十条以内。

2.2 传输格式:4 字节大端长度加 JSON 载荷

TCP 是字节流,不是消息流。一次readyRead可能只收到半条消息,也可能一次收到三条粘在一起。所以必须自己在应用层做分帧。源码里用的是最常见的方案:每一帧开头 4 个字节写载荷长度(大端序),后面跟 UTF-8 编码的 JSON。

// 发送一帧:quint32 大端长度 + Compact JSON 载荷 void Peer::sendFrame(const QJsonObject &obj) { const QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact); // 紧凑格式,省带宽 QByteArray frame; QDataStream ds(&frame, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); // 收发两端必须一致 ds << quint32(payload.size()); // 先写长度头 frame.append(payload); m_socket->write(frame); // 非阻塞,入发送缓冲即返回 }

接收侧的关键是“缓冲区 + 循环拆包”,一次readyRead里把所有完整帧都取出来,剩下的半帧留在缓冲区等下一次:

void Peer::onReadyRead() { m_buf.append(m_socket->readAll()); // 先全量收进本地缓冲 while (m_buf.size() >= 4) { QDataStream ds(m_buf); ds.setByteOrder(QDataStream::BigEndian); quint32 len = 0; ds >> len; if (m_buf.size() < int(4 + len)) break; // 半包:长度够了但内容还没到齐 const QByteArray payload = m_buf.mid(4, len); m_buf.remove(0, 4 + len); // 消费掉这一帧 handleMessage(QJsonDocument::fromJson(payload).object()); } }

len是载荷字节数,不含 4 字节头;m_buf是成员变量而不是局部变量,这正是能扛住半包的原因。为什么不直接传 C++ 结构体二进制?因为结构体会遇到字节序、编译器对齐填充、字段增删后新旧版本不兼容三个问题,调试时还只能看十六进制。JSON 的代价是体积大一些,五子棋一局不过几十条消息,这点开销可以忽略,换来的是抓包能直接看懂协议内容。这是排查问题时最有价值的属性。

提示:QJsonDocument::fromJson失败时返回空对象,handleMessage里要判obj.isEmpty()后直接丢弃,不要往下走,否则后续obj["type"].toString()拿到空字符串会让状态机进到难以复现的分支。

2.3 线程模型:异步 socket 不需要手写线程

一个常见误判是“网络要单独开线程,不然界面卡”。Qt 的QTcpSocket本身是事件驱动的非阻塞对象,读写都在事件循环里完成,正常收发不会阻塞界面。真正导致卡顿的是在 UI 线程里写waitForReadyRead()、waitForConnected()这类同步等待,或者收到大包后做了重计算。源码的客户端全程单线程,服务端也只在main里跑一个事件循环,所有连接对象挂在QTcpServer下用父子关系管理。

服务端每接受一个连接就new ClientSession(socket, this),把 socket 的parent设为 session,session 的parent设为 server。这样客户端断开时deleteLater()一次就能把 socket 一并回收,不会留下半死不活的对象。这个父子链是这个项目里最省事的设计之一,后面排查内存泄漏时能少看很多地方。

如果非要多线程,正确做法是把整组对象moveToThread,而不是在某处加锁——但联机五子棋的数据量根本用不着,加了反而多一类死锁风险。

3. 棋盘与胜负判定:15×15 定长数组与四方向扫描

棋盘这部分是整个项目里最容易写对、也最容易被写复杂的地方。数据结构选得朴素,后面每一处代码都会跟着简单。

3.1 棋盘用定长二维数组而不是容器套容器

static constexpr int kSize = 15; // 15 路棋盘 enum Stone { Empty = 0, Black = 1, White = 2 }; struct Board { Stone grid[kSize][kSize] = {}; // 值初始化,全 Empty bool inRange(int x, int y) const { return x >= 0 && x < kSize && y >= 0 && y < kSize; } bool isEmpty(int x, int y) const { return inRange(x, y) && grid[x][y] == Empty; } bool place(int x, int y, Stone s) { if (!isEmpty(x, y)) return false; grid[x][y] = s; return true; } };

15×15 一共 225 个格子,一个Stone枚举占 4 字节,整块 900 字节,随便拷贝、随便传值。用std::vector<std::vector<Stone>>除了多一次间接寻址、多一层容量管理,没有任何好处;而且在服务端要频繁做“棋盘是否为空”的判断时,数组的memcmp式遍历更直接。

坐标用(x, y)还是(row, col)无所谓,关键是全项目统一。源码统一用(x, y)表示列、行,绘图和协议里都是这个顺序,一旦某一层写成(y, x),出现的现象是棋子落在对角线的镜像位置,很难第一时间反应过来。

3.2 胜负判定:从落子点向四个方向展开

不需要每次全盘扫描。只有刚落下的那颗子可能形成五连,所以从它出发,沿四个轴向各取正负两个方向计数,累计达到 5 即胜。

// 只检查最后一手 (x,y),返回是否五连 bool Board::checkWin(int x, int y, Stone s) const { static const int dx[4] = { 1, 0, 1, 1 }; // 横、竖、主对角、副对角 static const int dy[4] = { 0, 1, 1, -1 }; for (int d = 0; d < 4; ++d) { int cnt = 1; // 包含落下的这一颗 for (int sign = -1; sign <= 1; sign += 2) { int nx = x + dx[d] * sign; int ny = y + dy[d] * sign; while (inRange(nx, ny) && grid[nx][ny] == s) { ++cnt; nx += dx[d] * sign; ny += dy[d] * sign; } } if (cnt >= 5) return true; // 长连也判胜 } return false; }

sign循环两次,分别向两端延伸;cnt初值为 1 表示落子点自身。四个方向向量里,副对角是(1, -1),往下往左,不能写成(1, 1),否则主副对角线会被当成同一条。这个错误的表现是“斜着连成五个不判胜,另一条斜线却乱判”,属于典型的手写方向表笔误。

是否允许长连(六子及以上)判胜,取决于规则约定。源码按“达到 5 即胜”处理,实现上就是>= 5。如果要做禁手规则,判定逻辑会复杂一个量级,通常不作为课程设计的必需项。

3.3 屏幕坐标与棋盘坐标的换算

绘图用paintEvent里的QPainter:先按边距和格宽算出 15 条横线和 15 条竖线,再画五个星位点,最后遍历grid画棋子。格宽和边距是常量,绘图和点击命中都用同一组,不能各写一份。

static constexpr int kMargin = 30; // 棋盘外框到第一条线的距离 static constexpr int kCell = 32; // 相邻两条线的间距 // 鼠标坐标 -> 交叉点下标;点得太偏返回 false bool BoardView::toGrid(const QPoint &pos, int &gx, int &gy) const { gx = (pos.x() - kMargin + kCell / 2) / kCell; // +半格实现四舍五入 gy = (pos.y() - kMargin + kCell / 2) / kCell; if (gx < 0 || gx >= kSize || gy < 0 || gy >= kSize) return false; // 点在棋盘外 // 距离交叉点超过半格视作误触,避免贴边误落 const QPoint center(kMargin + gx * kCell, kMargin + gy * kCell); return (pos - center).manhattanLength() <= kCell / 2; }

+ kCell / 2的作用是把“离哪个交叉点最近”变成整除截断,省掉一次qRound。末尾用曼哈顿距离再筛一次,是为了避免用户点在两个交叉点正中间时被强行吸附到其中一格——这类误触在手机端尤其明显。manhattanLength()比算平方根快,精度对这种场景完全够用。

绘制时把最后一手用不同颜色的小方框标出来,是个很小的改动,但联机对局里非常有用:对面落子后你能立刻看到位置,不用自己数格子。

4. 房间与对局流程:状态机、服务端校验与断线处理

网络部分能不能算“完成”,看的是异常路径。正常落子谁都能跑通,真正体现设计质量的是对面掉线、重复请求、越权落子这几种情况。

4.1 房间模型用一个显式状态机

struct Room { QString id; // 房间号,例如 "R0001" ClientSession *black = nullptr; // 黑方连接 ClientSession *white = nullptr; // 白方连接,为空表示等待中 Board board; Stone turn = Black; enum State { Waiting, Playing, Finished } state = Waiting; QTimer *turnTimer = nullptr; // 每回合倒计时,可选 };

状态迁移只有三条边:两名玩家就位后Waiting → Playing;一方五连或认输后Playing → Finished;Finished后房间延时回收或双方同意再来一局回到Waiting(清空棋盘、交换黑白)。把这三条边写成一个switch,别用一堆布尔标志(isGaming、isOver、isReady),多个布尔变量组合出的状态空间是 8 种,实际只该有 3 种,剩下 5 种就是 bug 温床。

服务端用QHash<QString, Room*>存房间,客户端连接进来先发LOGIN,服务端回房间列表或直接分配房间。两个人够了就开局,服务端主动推START,消息里带自己的执子颜色——客户端不需要自己算谁先手,减少一处可能不一致的地方。

4.2 落子校验:把所有作弊路径挡在服务端

这是整个项目里最该写厚的一段。客户端发上来的坐标永远不可信,服务端要按顺序检查四件事:房间是否在Playing、发送者是不是当前回合方、坐标是否在盘内、目标格是否为空。四项全过才落子并广播。

void Server::onMoveRequest(ClientSession *s, int x, int y) { Room *r = roomOf(s); if (!r || r->state != Room::Playing) { sendError(s, "对局尚未开始"); return; } if (s != sessionOf(r->turn)) { // 不是你的回合 sendError(s, "还没轮到你落子"); return; } if (!r->board.inRange(x, y) || !r->board.isEmpty(x, y)) { sendError(s, "该位置不可落子"); return; } const Stone me = r->turn; r->board.place(x, y, me); broadcastMove(r, x, y, me); // 双方(含自己)都靠这条消息更新界面 if (r->board.checkWin(x, y, me)) { r->state = Room::Finished; broadcastResult(r, me); // 带胜方与结束原因 return; } if (r->board.isFull()) { // 和棋 r->state = Room::Finished; broadcastResult(r, Empty); return; } r->turn = (me == Black ? White : Black); // 换手 }

注意顺序:先校验、再落子、再判胜、最后换手。换手写在判胜之前是个常见的顺序错误,会导致“赢了之后回合还切给对方”,下一局开局时先手颜色错乱。另一个细节是广播包含发送者自己,客户端不本地预落子,全靠这条MOVE消息驱动重绘,这样两边界面天然一致。

错误回包也要带上原因字符串。调试阶段你会非常依赖它——它决定了你是花五分钟还是半小时定位问题。

4.3 断线、超时与重连

三个场景分开处理:

  • 对局中断线:disconnected信号里把房间状态置Finished,给另一方推OPPONENT_LEFT,界面上弹一个非阻塞提示,允许直接退出。别做“等 30 秒”的复杂超时,课程设计阶段收益低。
  • 等待中断线:直接释放座位,房间回到Waiting,允许新玩家加入。
  • 重复连接:同一个session重复发LOGIN时,先比对已有房间里的连接对象,相同则忽略,避免一个连接占两个座位。

回合超时用QTimer::singleShot挂在Room上,每次换手重启。超时判负的提示要写明原因,不然对面看到自己突然输了会当成 bug。

4.4 协议消息一览

方向type关键字段说明
C→SLOGINname上线,服务端分配或返回房间
S→CROOM_LISTrooms[]房间号、人数、状态
C→SJOINroomId申请加入某房间
S→CSTARTcolor,turn开局,告知己方颜色与先手方
C→SMOVEx,y请求落子,不含颜色(服务端判定)
S→CMOVE_OKx,y,color广播落子结果
S→CGAME_OVERwinner,reason五连 / 和棋 / 对方离开
C→SRESTART—请求再来一局
S→CERRORmsg校验失败原因

MOVE请求里刻意不带颜色,颜色完全由服务端按turn决定。客户端如果能指定颜色,改一个字段就能连下两手。

5. 避坑与常见问题排查:几次联机对不上之后攒下的经验

下面五条都是这个项目里真实会遇到的典型问题,按“现象—原因—解决”整理,调试时可以当检查表用。

5.1 现象:偶尔“卡死”,一方落子后另一方毫无反应

原因几乎总是半包/粘包。readyRead触发一次不代表收到一条完整消息,如果代码里写成auto data = socket->readAll(); auto obj = QJsonDocument::fromJson(data).object();,在网络状况好的本机上可能几十局都不出错,一换成无线局域网就开始随机丢失。解决就是第 2.2 节的缓冲区循环拆包,长度头没凑齐或载荷没到齐就break等下一次信号。加上之后这类问题会彻底消失。

5.2 现象:点“连接”后界面白屏几秒,鼠标点哪都没反应

原因是在 UI 线程里调用了waitForConnected(3000)或waitForReadyRead。这些是同步阻塞接口,会把事件循环卡住,paintEvent排不上队。解决是把连接改成异步:调connectToHost()后立刻返回,成功走connected信号,失败走errorOccurred信号,界面在这期间保持可响应,连不上就弹提示。

提示:errorOccurred里的错误串要原样打到日志,ConnectionRefusedError(服务端没起)和HostNotFoundError(地址写错)是完全不同的两类问题,不看错误码只能靠猜。

5.3 现象:本机双开能下,换一台机器就提示连接失败

原因通常是地址写成了127.0.0.1,或者服务端监听时绑定了QHostAddress::LocalHost。回环地址只在同一台机器内有效。解决是让服务端监听QHostAddress::Any,客户端在界面上提供一个地址输入框,填服务端局域网 IP。同时确认双方在同一网段、服务端进程所在机器的入站规则允许该端口通过。这类问题排查顺序固定:先ping通不通,再看端口通不通,最后看应用层协议。

5.4 现象:对面明明没连成五个,自己却输了;或者自己连成了,对面没反应

两个方向的原因不同。前者多半是客户端本地也做了一份胜负判定,并且服务端用的棋盘坐标顺序和客户端不一致(比如服务端存[y][x],客户端存[x][y]),本机双开时两边同错所以看不出来,换两台机器就露馅。后者是判胜后没有广播GAME_OVER,只更新了服务端内部状态。解决办法只有一条:胜负只在服务端判一次,客户端收到GAME_OVER才结束对局,坐标顺序在协议文档里写死并全局统一。

5.5 现象:连续开关几局后程序崩溃,崩溃点在一个已经关掉的连接上

原因是对局结束删房间时,被删对象还挂在信号槽连接里,或者delete之后仍有排队的事件指向它。解决是统一用deleteLater()而不是delete,让对象在事件循环安全点销毁;析构前先disconnect(m_socket, nullptr, this, nullptr)断开所有连接;房间容器里存指针的话,删除的同时从QHash里remove,避免野指针被二次访问。这类问题没有“后悔药”,只能在对象生命周期上一次性写规矩。

6. 验收与答辩:用三台机器和一份日志证明它真能联机

答辩现场最容易被问倒的不是代码写得多漂亮,而是“你怎么证明它真的是联机的”。单机双开窗口看起来和联机一模一样,评审没法从界面上分辨。我的做法是把验收拆成可以当场演示的动作序列,每一步都有可见产物。

先准备三台设备(两台笔记本加一台台式机就够,同一局域网),其中一台跑服务端,另外两台跑客户端。演示顺序固定:

步骤操作预期现象留下的证据
1服务端启动,日志打印监听地址与端口控制台输出listen on 0.0.0.0:9000截图
2客户端 A 连接并登录服务端日志出现session A connected,房间列表多一行服务端日志
3客户端 B 连接并加入同一房间双方同时进入棋盘界面,A 执黑两端截图
4A 落子只有B 的棋盘上出现黑子,A 本地也画出来(来自广播)两端截图时间戳
5B 在 A 的回合强行点棋盘界面提示“还没轮到你落子”,棋盘不变截图 + 客户端日志
6B 拔掉网线A 收到“对方已离开”,对局结束,A 可返回房间列表截图
7A 沿一条斜线连成五子双方同时弹出结算,胜方为黑两端截图

第 4 步和第 5 步是重点。第 4 步要强调“A 的界面也是等服务端广播才更新”,可以让 A 在落子后立刻看服务端日志,顺序是recv MOVE→send MOVE_OK。第 5 步用来证明服务端校验确实生效,不是客户端自觉。第 6 步证明异常路径处理过。

日志部分,服务端建议按固定格式打印,一行一条,字段用制表符隔开:

// 每条进出消息都留痕,答辩时直接滚屏 qInfo().noquote() << QDateTime::currentDateTime().toString("hh:mm:ss.zzz") << "recv" << sessionId << type << QString("(%1,%2)").arg(x).arg(y);

noquote()的作用是避免字符串字段被加上引号,日志看起来更干净;zzz是毫秒,用来证明两条消息的先后顺序。有了它,任何“谁先谁后”的争议都能当场翻日志对照,不用靠回忆。

答辩时我还习惯准备一个反向演示:把服务端的turn校验那几行临时注释掉,让 B 在 A 的回合落子成功,界面出现连续两颗黑子,然后再恢复。这个对比能让评审在两分钟内理解“服务端权威”到底指什么,比讲十分钟设计原则有用。做这个演示的前提是代码里已经把那几行校验单独抽出来了,这也是我在设计阶段就把它单独成函数的原因。

从那以后我每次交这类联机项目,都会强制自己先用两台机器跑一遍上面的表,再准备一份能滚屏的服务端日志——代码写得再顺,没有这份日志,答辩现场就很容易变成“我说它能联机”。希望这份拆解能帮你少走几段弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 4:41:59

SD-WAN技术解析:企业网络架构优化实战

1. 项目概述&#xff1a;SD-WAN如何重构企业网络架构刚接手某跨国企业网络改造项目时&#xff0c;他们的MPLS专线账单让我倒吸一口凉气——每月光跨境链路费用就抵得上一个小团队的薪资。更棘手的是&#xff0c;每当新开海外办事处&#xff0c;从申请专线到业务上线至少要45天&…

作者头像 李华
网站建设 2026/11/2 4:40:08

AI测试工具如何提升金融系统上线前的缺陷检测率

1. 项目背景&#xff1a;AI如何成为上线前的最后防线上周五下午4点15分&#xff0c;距离我们团队耗时三个月开发的金融风控系统正式上线还剩1小时。例行检查中AI测试工具突然发出红色警报——在模拟极端市场波动场景下&#xff0c;系统核心算法会出现概率性崩溃。这个被传统测试…

作者头像 李华
网站建设 2026/11/2 4:39:50

Azkaban Solo Server SNAPSHOT 部署避坑与验证

简介&#xff1a;Azkaban Solo Server 0.1.0-SNAPSHOT 的已编译单机版分发包&#xff0c;面向需要快速搭建工作流调度环境的小型团队、个人开发者及大数据作业调度初学者&#xff0c;省去从源码编译和手工拼装组件的麻烦。资源包约42.28MB&#xff0c;采用tar.gz格式&#xff0…

作者头像 李华
网站建设 2026/11/2 4:39:39

Redis Stream在电商秒杀系统中的应用与实践

1. 秒杀场景下的技术挑战与解决方案选型在电商系统中&#xff0c;秒杀活动是最典型的高并发场景之一。当某件热门商品以极低价格限量发售时&#xff0c;系统往往会在瞬间承受平时数十倍甚至上百倍的流量冲击。这种突发流量会带来三个核心问题&#xff1a;数据库压力陡增&#x…

作者头像 李华
网站建设 2026/11/2 4:39:04

Tarjan算法:图论中的强连通分量与关键结构检测

1. Tarjan算法概述Tarjan算法是由计算机科学家Robert Tarjan在1972年提出的一系列图论算法&#xff0c;主要用于解决图的强连通分量、桥和割点等问题。这个算法基于深度优先搜索(DFS)策略&#xff0c;通过维护一个栈和两个关键数组来高效地识别图中的关键结构。在实际应用中&am…

作者头像 李华
网站建设 2026/11/2 4:37:07

OpenClaw架构解析:从聊天机器人到行动代理的技术演进

1. OpenClaw架构概览&#xff1a;从聊天机器人到行动代理的进化2026年的AI代理领域正在经历一场范式转移&#xff0c;OpenClaw作为这场变革的先锋&#xff0c;重新定义了人机交互的边界。与传统的聊天机器人不同&#xff0c;OpenClaw采用了双层架构设计&#xff1a;底层的多模态…

作者头像 李华