简介:基于Qt框架开发的中国象棋网络对战项目,是一套可直接运行的C++源码。它面向有一定C++基础、希望深入理解网络编程与多线程并发开发的读者,解决了如何在Qt中搭建一个支持多玩家同时在线对弈的平台问题。服务器端以TCP协议作为通信基础,通过QThread为每个接入的客户端分配独立线程,实现高并发处理;客户端完整实现了棋盘绘制、棋子走法合法性判断、吃子与胜负判定,并使用信号槽机制完成线程间通信与界面更新。压缩包共有二十一个文件,包括十个C++源文件、九个头文件以及Qt工程配置文件,整体体量仅十六KB,模块划分清晰,涵盖网络通信、多线程处理、游戏逻辑、用户界面、并发控制、状态管理和错误处理等典型模块,适合作为课设或Qt综合开发的参考案例。目前已有四百零八人学习浏览,对掌握TCP通信细节、线程同步和跨线程消息传递具有实际帮助。
1. 一个人写象棋不难,难的是让两台机器坐下来下完一整盘
基于QT的中国象棋网络对战实现,说白了就是把本地棋盘的走法校验、胜负判断搬到客户端,再在中间架一条TCP通道让两个客户端互相承认对方的落子。单机版中国象棋网上教程一抓一大把,但网络对战版本才是真正让人卡住的地方:协议怎么定、半包粘包怎么拆、双方棋盘状态怎么保持一致、掉线了怎么收场。这篇文章按我实际做过一遍的顺序来写,从棋盘建模讲到网络同步,最后给你一套能跑通的落地方案。适合已经有QT基础、想拿网络实战项目练手或做课程设计的开发者,读完可以直接对着代码敲。
2. 棋盘与棋子的建模:用QGraphicsView把棋盘画出来,再把规则灌进内存
2.1 棋盘数据结构:9乘10的数组为什么是最省心的选择
中国象棋棋盘是9列10行,最直观的建模方式是二维数组。我之前见过有人用坐标链表、甚至用图结构去存棋子位置,最后都绕回来了——数组就是最简单可靠的方案。定义如下:
enum PieceType { NONE, ROOK, KNIGHT, BISHOP, GUARD, KING, CANNON, PAWN }; enum PieceColor { EMPTY, RED, BLACK }; struct Piece { PieceType type; PieceColor color; int row; // 0 ~ 9 int col; // 0 ~ 8 }; Piece board[10][9]; // board[row][col]数组下标从0开始,board[0][0]是红方左下角,board[9][8]是黑方右上角。红方从下方往上走,黑方从上方往下走,这个方向约定会直接影响走法校验代码——你的棋子坐标增长方向和你的视角逻辑要一致,否则后面写走法时全是正负号问题。
数据结构定下来后,初始化棋盘就是把每个兵种放到它的起始交叉点上。这里有个小经验:不要在构造函数里硬编码每个坐标,用一个静态的初始化表迭代赋值,后面扩展残局摆盘功能时直接复用。
void initBoard() { // 初始化所有格子为空 for (int r = 0; r < 10; r++) for (int c = 0; c < 9; c++) board[r][c] = { NONE, EMPTY, r, c }; // 用起始布局表摆放棋子,黑方在0~4行,红方在5~9行 int startLayout[10][9] = { // row 0: 黑方底线 {ROOK, KNIGHT, BISHOP, GUARD, KING, GUARD, BISHOP, KNIGHT, ROOK}, // row 1: 黑方卒线 {NONE, NONE, NONE, NONE, CANNON, NONE, NONE, NONE, NONE}, // row 2: 黑方兵线 {PAWN, NONE, PAWN, NONE, PAWN, NONE, PAWN, NONE, PAWN}, // rows 3~6: 河界(空) // row 7: 红方兵线 {PAWN, NONE, PAWN, NONE, PAWN, NONE, PAWN, NONE, PAWN}, // row 8: 红方炮线 {NONE, NONE, NONE, NONE, CANNON, NONE, NONE, NONE, NONE}, // row 9: 红方底线 {ROOK, KNIGHT, BISHOP, GUARD, KING, GUARD, BISHOP, KNIGHT, ROOK} }; // 0~4行是黑方,5~9行是红方,按行赋值 }这里注意:Piece结构体里同时存了row和col,这意味着同一个棋子的位置信息存在两份地方——数组下标和结构体字段。我建议把结构体里的row/col当作棋子的「逻辑锚点」,数组下标作为「实际存储位置」,每次移动时两者同步更新。这份冗余在实现悔棋和历史记录时很有用,因为你可以通过棋子指针快速拿到它上一次在哪,而不是遍历整个棋盘找它。
2.2 走法合法性校验:马蹩腿、炮翻山、帅对脸,一个switch解决不完
走法校验是对战项目里最容易写崩的部分,也是网络对战里双方能否信任对方的关键。常见做法是写一个isLegalMove(fromRow, fromCol, toRow, toCol)函数,返回布尔值。每种棋子的规则拆开写:
bool isLegalMove(int fr, int fc, int tr, int tc) { Piece& p = board[fr][fc]; if (p.type == NONE || (board[tr][tc].color == p.color)) return false; // 目标格是己方棋子,非法 int dr = tr - fr, dc = tc - fc; switch (p.type) { case ROOK: return isPathClear(fr, fc, tr, tc); case KNIGHT: return isKnightLegal(fr, fc, tr, tc); case BISHOP: return isBishopLegal(fr, fc, tr, tc); case GUARD: return isGuardLegal(fr, fc, tr, tc); case KING: return isKingLegal(fr, fc, tr, tc); case CANNON: return isCannonLegal(fr, fc, tr, tc); case PAWN: return isPawnLegal(fr, fc, tr, tc); default: return false; } }isPathClear是车和炮都要用的公共函数,逻辑是:沿着行或列从起点向终点走,计算中间经过的棋子数。车要求中间棋子数为0,炮要求中间恰好隔1个棋子(也就是「炮架子」),这是最常见的细节坑。
马的蹩腿是新手最容易翻车的地方。马走「日」,但蹩腿方向和行进方向相关:
bool isKnightLegal(int fr, int fc, int tr, int tc) { int dr = abs(tr - fr), dc = abs(tc - fc); if (!((dr == 1 && dc == 2) || (dr == 2 && dc == 1))) return false; // 蹩腿:向哪边走,就检查对应方向的相邻交叉点有没有棋子 if (dc == 2) { int blockCol = fc + (tc > fc ? 1 : -1); if (board[fr][blockCol].type != NONE) return false; } else if (dr == 2) { int blockRow = fr + (tr > fr ? 1 : -1); if (board[blockRow][fc].type != NONE) return false; } return true; }另外两个容易漏掉的规则是「将帅对脸」和「过河兵」。将帅不能在同一列且中间无棋子直接相对,这在走完一步之后要检查;兵没过河只能直走一格,过河后才能横走。这两个往往不是写在isLegalMove里,而是写在一个更大的canMove流程里。我一般分两层:isLegalMove只管棋子自身规则,canMove再管「走完这步己方将帅会不会被将军」。分层的价值在于网络对战里两端只要同步执行同一套规则,就不会出现一方说赢了一方说还好的局面。
2.3 绘制与交互基座:QGraphicsScene、Item与自定义图元
棋盘绘制有两种主流路线:QWidget重写paintEvent,或者用QGraphicsView + QGraphicsScene。做网络对战我强烈推荐后者——QGraphicsView 自带高效的碰撞检测和 Item 事件分发,鼠标选棋、拖拽、选中高亮这些交互写起来比手写paintEvent省一半功夫。
我常用的结构是:
class ChessBoardView : public QGraphicsView { Q_OBJECT public: ChessBoardView(QWidget* parent = nullptr); private: QGraphicsScene* m_scene; QList<ChessItem*> m_items; // 每个棋子对应一个图元 }; class ChessItem : public QGraphicsObject { Q_OBJECT public: ChessItem(int row, int col, PieceType type, PieceColor color); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; void mousePressEvent(QGraphicsSceneMouseEvent* event) override; // 存储初始位置,用于拖拽后判断合法性 };这里的关键设计是把棋子的「数据模型」(Piece)和「视图表现」(ChessItem)分开。数据模型只管规则,视图只管画圆和字。网络对战里收到对端走棋消息时,只更新数据模型,然后调用item->setPos()移动图元,不需要重建场景。
坐标换算也要提前想好:每个交叉点对应场景里的固定像素坐标。棋盘左边距MARGIN,格子宽度CELL_SIZE,那么scenePos = QPointF(MARGIN + col * CELL_SIZE, MARGIN + row * CELL_SIZE)。这些参数最好做成常量,后面调UI间距时只改一处。
3. 网络对战核心:QTcpServer、自定义协议与房间匹配
3.1 先定协议:两字节长度头加JSON体,比纯JSON流少踩多少坑
网络对战第一步是制定应用层协议。最省事但最坑的做法是直接发JSON字符串,接收端用\n分割——因为JSON本身可能包含换行转义,而且TCP是字节流,你不知道一条消息什么时候结束。
我推荐的四字节定长头方案是:前2字节存消息体长度(网络字节序),后面跟JSON。接收端维护一个缓冲区,先读2字节判断长度,再按长度截取完整消息。代码实现如下:
void NetworkClient::onReadyRead() { // m_buffer 是 QByteArray,作为累积缓冲区 m_buffer.append(m_socket->readAll()); while (m_buffer.size() >= 2) { // 读取前2字节作为消息长度(大端序) quint16 msgLen = (quint8(m_buffer[0]) << 8) | (quint8(m_buffer[1])); if (m_buffer.size() < 2 + msgLen) { return; // 半包:数据还没攒够,等下一次 onReadyRead } QByteArray jsonData = m_buffer.mid(2, msgLen); m_buffer.remove(0, 2 + msgLen); handleMessage(jsonData); } }这个while循环一次能处理多条完整消息,解决粘包问题;return等待下一次触发解决半包问题。协议字段设计上,消息类型放在JSON的type字段里:
{"type": "move", "fromRow": 3, "fromCol": 1, "toRow": 4, "toCol": 1, "timestamp": 1710000000}消息类型至少需要:match_request请求匹配、match_started匹配成功(带先后手信息)、move走棋、move_result校验结果、heartbeat心跳、disconnect主动断线。JSON虽然比自定义二进制多十几个字节的冗余,但在调试期你直接能看报文内容,这收益远大于那点带宽成本。
3.2 房间匹配机制:最简单的服务器中转和房间概念
两个客户端要下棋,必须有「匹配」这个动作。最简单可靠的模型是服务器维护一个等待队列和一个房间列表。
我做过的最小实现是:服务器启动QTcpServer监听端口,客户端连接后发送match_request,服务器把它放入waitingQueue;当队列里有2个客户端时,创建Room对象,把两个QTcpSocket*放进房间,然后向双方广播match_started,并随机(或者按先来后到)指定红黑方。
void Server::onNewConnection() { QTcpSocket* socket = m_server->nextPendingConnection(); connect(socket, &QTcpSocket::readyRead, this, &Server::onReadyRead); connect(socket, &QTcpSocket::disconnected, this, &Server::onDisconnected); m_sockets.append(socket); } void Server::handleMatchRequest(QTcpSocket* socket) { m_waitingQueue.enqueue(socket); if (m_waitingQueue.size() >= 2) { QTcpSocket* p1 = m_waitingQueue.dequeue(); QTcpSocket* p2 = m_waitingQueue.dequeue(); // 先连的执红,后连的执黑 sendJson(p1, "match_started", "red"); sendJson(p2, "match_started", "black"); } }这个实现只适合两人的简单对战,房间数量多了就需要引入Room类管理状态:谁掉线了、当前轮到谁、棋盘数据快照。教室级并发不用考虑高并发架构,但至少不要用全局变量存p1/p2,否则第三个人连上来就把前两个人顶掉了。用QHash<int, Room*>以房间ID为键管理,是扩展后续观战和战绩记录的基础。
3.3 心跳包与超时检测:判断“掉线”的最可靠方式
TCP本身有TCP_KEEPALIVE机制,但触发时间默认是2小时,对棋类对战这种需要秒级感知掉线的场景完全不可用。所以要在应用层做心跳。
方案是:客户端每3秒发一个{"type":"heartbeat"},服务器每收到一个就刷新该连接的最后活跃时间;服务器每隔5秒扫描一次所有连接,发现超过10秒没有活跃的连接,就判定掉线并触发对应处理。
void Server::startHeartbeatCheck() { m_heartbeatTimer = new QTimer(this); connect(m_heartbeatTimer, &QTimer::timeout, this, [this]() { qint64 now = QDateTime::currentSecsSinceEpoch(); for (auto it = m_lastActive.begin(); it != m_lastActive.end();) { if (now - it.value() > 10) { handleDisconnect(it.key()); it = m_lastActive.erase(it); } else { ++it; } } }); m_heartbeatTimer->start(5000); // 每5秒检查一次 }心跳间隔和超时阈值是两个需要配合的参数。间隔太短会浪费流量,太长则掉线感知慢。我习惯取值是间隔3秒、超时10秒,中间留了3倍余量。如果对端只是短暂卡顿,这个参数不会误判断线;如果真的断网,10秒内对局就能被终止并提示。
提示:不要把心跳包和对局消息混在一个线程里处理。QTcpSocket 的 readyRead 信号本身是事件驱动的,如果对端在一次大数据传输后不再发心跳,你的 UI 也会因为事件循环被阻塞而卡住。心跳检测的 QTimer 放在主线程没问题,但不要在 timeout 处理里做耗时操作。
4. 走棋同步与界面交互:选子、落子、动画与回合状态机
4.1 鼠标选棋与走子:事件处理和规则校验的先后顺序
客户端交互流程一般是:点击棋子 → 选中并高亮 → 再次点击目标格 → 本地做isLegalMove校验 → 通过则发送走棋消息给服务器 → 服务器转发给对端并回执给本方。
这里有一个顺序问题:先发消息还是先动棋子?必须先发消息、再等确认、然后动。本地先动会导致「本地赢了但服务器说非法」的错位。我在实现时是把本地校验当「预校验」,真正的权威校验放在服务器端——服务器收到move消息后,用同一份规则再校验一次,然后广播move_result给双方。这样即使客户端被篡改,服务器也能兜底。
void Client::onChessItemClicked(ChessItem* item) { if (m_currentTurn != m_myColor) return; // 没轮到我不响应 if (!m_selectedItem) { // 第一次点击:选中己方棋子 if (item->color() == m_myColor) { m_selectedItem = item; item->setHighlight(true); } } else { // 第二次点击:尝试落子 int tr = item->row(), tc = item->col(); if (isLegalMove(m_selectedItem->row(), m_selectedItem->col(), tr, tc)) { sendMove(m_selectedItem->row(), m_selectedItem->col(), tr, tc); } else { // 点到了非法位置,如果点的是己方另一个棋子,切换选中 if (item->color() == m_myColor) { m_selectedItem->setHighlight(false); m_selectedItem = item; item->setHighlight(true); } } } }注意这里选中棋子的高亮状态要用QGraphicsObject的update()触发重绘,不要直接改坐标。如果用的是QGraphicsEffect做发光效果,网络对战这种高频刷新场景会出现残影,我后来放弃了这个效果,改用红色边框线,简单且性能极好。
4.2 回合状态机:谁轮到、谁锁定、状态怎么广播
网络对战和单机版最大的区别在于:单机版的回合切换是同步的、瞬间的,网络版是异步的,你需要一个显式的状态机。
我用的是三个状态:
enum GamePhase { WAITING_MATCH, // 等待匹配 RED_TURN, // 轮到红方 BLACK_TURN, // 轮到黑方 GAME_OVER // 对局结束 };状态机的事件只有两种:本地走棋成功、收到对端走棋成功。每当服务器广播move_result且accepted=true时,双方执行相同的状态迁移:RED_TURN -> BLACK_TURN或BLACK_TURN -> RED_TURN。
我在这踩过一个坑:状态只在客户端各维护一份,服务器不参与。只要有一方因为网络延迟多收到一次重发的move_result,两端状态就错乱了。解决方法是给每条走棋消息加单调递增的seq序号,接收方只处理比当前最大seq大1的消息,重复消息直接丢弃。这个技巧简单但极其关键。
4.3 落子动画与服务端校验:用动画掩盖网络延迟的常见做法
远程对战的延迟在局域网低于20ms,公网可能要50-200ms。如果每次落子都等网络往返再移动棋子,体验会非常涩。常见做法是本地立即执行落子动画(先行的模拟),同时把走棋消息发给服务器;如果服务器判定非法,再回滚。
void Client::onMoveAccepted(const QJsonObject& move) { int fr = move["fromRow"].toInt(); int fc = move["fromCol"].toInt(); int tr = move["toRow"].toInt(); int tc = move["toCol"].toInt(); // 找到对应的两个图元 ChessItem* fromItem = findItemAt(fr, fc); ChessItem* toItem = findItemAt(tr, tc); if (!fromItem) return; // 如果目标格有棋子,先把它从场景中移除(吃掉) if (toItem) { m_scene->removeItem(toItem); delete toItem; } // 移动棋子并播放动画 fromItem->setRow(tr); fromItem->setCol(tc); animateMove(fromItem, tr, tc); }动画本身用QPropertyAnimation操作pos:
QPropertyAnimation* anim = new QPropertyAnimation(item, "pos"); anim->setDuration(200); // 动画时长200ms,比网络延迟短但肉眼可感知 anim->setStartValue(item->pos()); anim->setEndValue(QPointF(MARGIN + tc * CELL_SIZE, MARGIN + tr * CELL_SIZE)); anim->setEasingCurve(QEasingCurve::OutCubic); anim->start(QAbstractAnimation::DeleteWhenStopped);这里的setDuration(200)是个经验值:低于100ms动画像瞬移,高于400ms会让人觉得棋子在“飘”。配合网络延迟约200ms,整体交互节奏刚好合适。被吃掉的棋子从场景里removeItem而不是隐藏,因为它还占据内存(如果之后要悔棋,需要恢复它)。
5. 网络对战的避坑指南:半包、乱码、闪退与端口玄学
网络对战项目的坑集中在网络协议和QT事件交互上,下面五条是我实际踩过、并且花时间最久的。
5.1 粘包与半包:TCP是字节流,不是消息队列
现象:客户端 A 连发两条move消息,客户端 B 的onReadyRead一次触发把两条数据全读走,第一条消息正常处理,第二条消息的JSON体断裂无法解析,程序直接跳过或崩掉。
原因:TCP 是流式协议,不保证应用程序定义的「消息边界」。发送端的两次write可能在底层被合并成一个数据段,接收端的readAll则可能一次读不够、也可能一次读多。
解决:就是第3.1节里写的长度头方案。核心是每次读取后必须把长度头和数据体分离,不能直接把readAll()的结果当JSON解析。这里最容易犯的错是只处理了一条消息就return,导致同一批数据里剩余的消息丢失。一定要用while循环处理到缓冲区不足为止。
提示:
QByteArray::remove会触发内存拷贝,如果消息频繁且体量大,会影响性能。对战场景消息体不到1KB,完全不用优化;如果做实时棋谱直播,再考虑用QBuffer或自研环形缓冲区。
5.2 中文走棋记录的编码陷阱:QByteArray与QString互转
现象:客户端界面显示棋谱「车一平二」变成了乱码车ä¸å¹³äº。
原因:QByteArray默认按UTF-8解析,但如果你在构建JSON时用的是QString::toLocal8Bit(),在Windows上会转成GBK,而对端在Linux上按UTF-8解码,就会出现经典的ä¸乱码。
解决:协议层统一用UTF-8。发送端QJsonDocument::toJson()输出本身就是UTF-8,接收端QJsonDocument::fromJson接收QByteArray时默认按UTF-8解析。唯一要小心的是不要把QString先转成toLocal8Bit再塞进JSON。我这个坑是在写自定义sendJson封装时用了toLocal8Bit,后来全局搜索toLocal8Bit全部删掉才解决。
5.3 网络模块与UI的线程问题:信号槽连接方式选错就闪退
现象:程序偶发闪退,报错信息是QObject::startTimer: Timers cannot be started from another thread,或者ASSERT failure in QCoreApplication::sendEvent。
原因:QTcpServer 的readyRead信号在底层线程(通常是socket所属线程)触发,如果你在槽函数里直接操作QGraphicsScene的图元,就会跨线程访问UI对象,QT检测到就会终止程序。
解决:确保网络对象(QTcpServer/QTcpSocket)和UI对象都创建在同一个线程中。常见做法是在MainWindow的构造函数里初始化QTcpSocket,利用信号槽默认的AutoConnection自动切换。如果你真的把网络逻辑单独拆了一个线程,那槽函数接收端的connect的第五个参数必须显式指定Qt::QueuedConnection,或者用信号里带QSharedPointer而不是裸指针。
我踩坑的是先把QTcpSocket创建在了NetworkThread对象里,结果onReadyRead里更新棋盘的代码时好时坏,加了QThread::msleep之后才意识到是线程问题。
5.4 端口与连接:bind失败不等于程序写错
现象:启动服务器端显示bind: Address already in use。服务端无法启动,换个端口又能跑。
原因:上一个程序异常退出,但没有释放端口。或者有两个实例同时监听同一端口。
解决:开发阶段加上QAbstractSocket::ReuseAddressHint:
m_server->listen(QHostAddress::Any, PORT);如果要强制复用,需要先设置setSocketOption(QAbstractSocket::LowDelayOption, 1)并在listen之前调用m_server->close()清残留。真正定位问题时用netstat -ano | findstr 8888(Windows)或lsof -i:8888(Linux)查端口占用进程,这一步能排除大量“以为代码错了”的时间。在QT客户端这边,连接失败的表现是errorOccurred信号,一定要连这个信号打日志,否则你会对着connectToHost发呆。
5.5 双端规则不一致:我改了我自己的棋,对端不认
现象:走棋动画正常播放,但对端棋盘纹丝不动。查日志发现move_result返回accepted=false。
原因:双方isLegalMove的代码不一致,比如红黑方视角的行号换算问题。红方走(7,0)->(6,0)是兵进一步,黑方如果要走同一位置必须走(2,0)->(3,0),你正确实现了红方视角,但黑方客户端用的是红方视角的逻辑。
解决:协议里传输的坐标必须是统一的绝对坐标(row从0到9、col从0到8),双方按自己的视角渲染。不要传输“相对方向”。如果走棋消息里带的是moveUp这样的方向指令,双端视角不统一时必出bug。网络对战的第一原则:只同步绝对状态,不同步操作意图。
6. 从能下到能玩:悔棋、倒计时与断线重连的进阶实现
6.1 悔棋操作与历史栈回放
悔棋的实现不能只做“撤销上一步”,因为网络对战中对方可能并不同意。我的做法是:发起方发送undo_request,对方点击同意后,服务器广播undo_confirm,双方同时从历史栈弹出两步棋(自己的和对方的),恢复被吃的棋子。历史栈里每步存完整状态快照:
struct MoveRecord { int fromRow, fromCol, toRow, toCol; Piece capturedPiece; // 被吃掉的棋子完整信息,用于恢复 }; QStack<MoveRecord> m_history;收到undo_confirm后,pop两次并反向执行:把移动的棋子放回去,把被吃的棋子addItem回场景。这个操作很考验坐标系的一致性,最好在悔棋前后各打一条日志。
6.2 超时判负与QTimer用法
每步限时是网络对战的刚需。在客户端用QTimer::singleShot实现超时判负:
void Client::startTurnTimer() { if (m_turnTimer) m_turnTimer->stop(); m_turnTimer = QTimer::singleShot(60000, this, [this]() { if (m_currentTurn == m_myColor) { sendTimeout(); } }); }这里的60*1000毫秒是限时时长,对应60秒走一步。注意QTimer::singleShot返回的是一个inttimerId,如果你在槽函数里再次调用startTurnTimer,必须先把前一个timer停掉,否则前一个到点仍然会触发回调,导致误判超时。我最初没停旧timer,结果每走一步棋就积累一个超时定时器,走到第10步时第1步的定时器也到点了,程序直接判负,血泪教训。
6.3 断线重连与会话恢复
断线重连是网络对战区别于单机版的最大进阶。常见设计是:客户端断线后保留本地的棋盘快照和roomId,每2秒尝试重连服务器,并发送reconnect消息携带roomId和playerId。服务器端在收到reconnect后,把当前棋盘状态、双方剩余时间、当前回合数发给重连方,让它把状态恢复到断线前。
这里最麻烦的是对端已经退出的情况。我的习惯做法是:服务器收到断线通知后,不立即销毁房间,而是启动一个60秒的QTimer等待重连;超时则宣判存活方获胜。这个60秒要大于心跳超时的10秒,否则对方还没来得及重连就被判负了。
我做这个项目最大的教训是:网络协议一定要从第一天就设计成带长度头的JSON,不要贪图省事用裸字符串。后面每次加字段只是加JSON key,不需要动协议栈。这个习惯让我在后面扩展观战、战绩、悔棋功能时长舒一口气。如果你也准备做这类QT项目,先从本地双人对战跑通规则,再往上加网络层,每一步都能独立验证,会比一次性写完全部代码好得多——希望帮到你。
本文还有配套的精品资源,点击获取