简介基于QT的中国象棋网络对战实现是一份面向C/Qt学习者的网络编程与多线程实战工程。项目以TCP通信为骨架、多线程并发为支撑并完整实现了中国象棋的走子规则、胜负判定与双人对战界面适合正在学习Qt网络模块、线程同步或游戏逻辑设计的开发者参考。压缩包共21个文件核心为10个cpp源码与9个h头文件另有1个pro工程文件和1个user配置整体仅16KB。源码覆盖服务器监听、客户端收发、棋盘绘制、单机模式、联机模式与控制面板等模块目录结构清晰便于按功能逐个研读。目前已有408人学习下载。从中可以了解QTcpServer与QTcpSocket如何搭建可靠连接QThread如何承担多客户端并发请求以及如何利用信号槽完成线程间通信与界面刷新。同时棋子合法性判断和吃子逻辑也能为游戏开发提供可直接套用的思路是一份麻雀虽小、五脏俱全的Qt综合案例。1. 基于QT的中国象棋网络对战先定协议还是先画棋盘如果你正在做或者准备做“基于QT的中国象棋网络对战”我劝你先把“网络对战”四个字放前面。很多教程把大量篇幅花在画棋盘和棋子移动上但真正让这个项目翻车的是两件事走法规则没有做到“一套逻辑两个端共用”以及网络协议没有按“服务端权威”设计。这个项目适合 Qt 基础已经过关的在校生做课程设计也适合桌面开发新人拿它练手 TCP 协议和 GUI 分离。它不复杂但要是把顺序做反了后面改起来会让你想删库跑路。下面按我实际做过的方案讲先立住棋局内核再画界面最后通网络最后把坑一个一个填上。2. 棋局内核设计一维数组、走法生成与将军判断2.1 棋盘与棋子的数据结构一维数组加下标换算常见做法是写int board[10][9]但我一般用一维数组长度 90。原因不是炫技而是网络协议里可以用一个quint8表示起点和终点不用每次把“行列”这种二维信息序列化也省去二维下标和网络字节之间的来回转换。enum PieceType { EMPTY 0, RED_KING 1, RED_ADVISOR, RED_ELEPHANT, RED_HORSE, RED_ROOK, RED_CANNON, RED_PAWN, BLACK_KING 11, BLACK_ADVISOR, BLACK_ELEPHANT, BLACK_HORSE, BLACK_ROOK, BLACK_CANNON, BLACK_PAWN }; class BoardState { public: static constexpr int BOARD_SIZE 90; PieceType pieces[BOARD_SIZE]; quint8 sideToMove 0; // 0 红方1 黑方 static int rowOf(int idx) { return idx / 9; } static int colOf(int idx) { return idx % 9; } static int indexOf(int row, int col) { return row * 9 col; } void reset() { memset(pieces, 0, sizeof(pieces)); const int redLine[9] { RED_ROOK, RED_HORSE, RED_ELEPHANT, RED_ADVISOR, RED_KING, RED_ADVISOR, RED_ELEPHANT, RED_HORSE, RED_ROOK }; const int blackLine[9] { BLACK_ROOK, BLACK_HORSE, BLACK_ELEPHANT, BLACK_ADVISOR, BLACK_KING, BLACK_ADVISOR, BLACK_ELEPHANT, BLACK_HORSE, BLACK_ROOK }; for (int col 0; col 9; col) { pieces[indexOf(9, col)] redLine[col]; pieces[indexOf(0, col)] blackLine[col]; } for (int col 0; col 9; col 2) { pieces[indexOf(6, col)] RED_PAWN; pieces[indexOf(3, col)] BLACK_PAWN; } pieces[indexOf(7, 1)] RED_CANNON; pieces[indexOf(7, 7)] RED_CANNON; pieces[indexOf(2, 1)] BLACK_CANNON; pieces[indexOf(2, 7)] BLACK_CANNON; } };这段代码把棋盘定死为 9 列 × 10 行下标 0 到 89。行 0 是黑方底线行 9 是红方底线红方棋子在下半场黑方在上半场。reset()里直接把象棋初始摆法写进去后面无论是界面刷新还是网络重建局面都从reset()开始。之所以把颜色编号拉开是为了在判断“是不是自家人”时不用再做一层枚举映射直接看数字范围就能分边。2.2 走法生成每种棋子的移动规则拆成纯函数走法规则不要写在 Widget 里更不要写在鼠标事件里。我一般拆成两个纯函数countBetween()负责统计两个交叉点之间的棋子数量stepRuleOk()负责单步走法是否满足象棋规则。它们不依赖界面不依赖网络只操作BoardState这样服务端和客户端可以共用同一份代码。int countBetween(const BoardState board, int from, int to) { int fRow BoardState::rowOf(from), fCol BoardState::colOf(from); int tRow BoardState::rowOf(to), tCol BoardState::colOf(to); if (fRow ! tRow fCol ! tCol) return -1; // 不在同一直线 int dr (tRow ! fRow) ? (tRow fRow ? 1 : -1) : 0; int dc (tCol ! fCol) ? (tCol fCol ? 1 : -1) : 0; int cnt 0; int r fRow dr, c fCol dc; while (r ! tRow || c ! tCol) { if (board.pieces[BoardState::indexOf(r, c)] ! EMPTY) cnt; r dr; c dc; } return cnt; } bool stepRuleOk(const BoardState board, int from, int to) { int piece board.pieces[from]; if (piece EMPTY || from to) return false; int sr BoardState::rowOf(from), sc BoardState::colOf(from); int tr BoardState::rowOf(to), tc BoardState::colOf(to); int dr tr - sr, dc tc - sc; int ar abs(dr), ac abs(dc); int side (piece 10) ? 0 : 1; if (board.pieces[to] ! EMPTY (board.pieces[to] 10) (side 0)) return false; // 不能吃自己人 int idx (piece 10) ? piece - 10 : piece; switch (idx) { case 1: { // 将 if (side 0 !(tr 7 tc 3 tc 5)) return false; if (side 1 !(tr 2 tc 3 tc 5)) return false; if (ar ac 1) return true; if (sc tc countBetween(board, from, to) 0) return true; return false; } case 2: // 士 if (side 0 !(tr 7 tc 3 tc 5)) return false; if (side 1 !(tr 2 tc 3 tc 5)) return false; return ar 1 ac 1; case 3: // 象 if (!(ar 2 ac 2)) return false; if (side 0 tr 5) return false; if (side 1 tr 4) return false; return board.pieces[BoardState::indexOf(sr dr / 2, sc dc / 2)] EMPTY; case 4: { // 马 if (!((ar 2 ac 1) || (ar 1 ac 2))) return false; int legRow, legCol; if (ar 2) { legRow sr dr / 2; legCol sc; } else { legRow sr; legCol sc dc / 2; } return board.pieces[BoardState::indexOf(legRow, legCol)] EMPTY; } case 5: // 车 return (dr 0 || dc 0) countBetween(board, from, to) 0; case 6: { // 炮 if (dr ! 0 dc ! 0) return false; int between countBetween(board, from, to); if (board.pieces[to] EMPTY) return between 0; return between 1; } case 7: // 兵 if (side 0) { if (dc 0 dr -1) return true; // 红兵向前 if (sr 5 ar ac 1 dc ! 0) return true; // 过河后可横走 } else { if (dc 0 dr 1) return true; // 黑卒向前 if (sr 4 ar ac 1 dc ! 0) return true; } return false; } return false; }这里最需要注意的是方向逻辑层固定红方在下方所以红兵前进是行号减 1黑卒前进是行号加 1。过河条件也按这个坐标方向判断红兵过河后行号小于 5黑卒过河后行号大于 4。马的“蹩马腿”和象的“塞象眼”都用中点格来判断落点是否越界在进入stepRuleOk前就由服务端保证但为了安全生成走法时仍要在调用处做 0~89 的范围检查。2.3 将军、绝杀与合法性走完再看自己是不是被照将单步规则只解决了“这颗子能不能这么动”还没解决“动了之后自家将帅会不会被吃”。这是新手最容易漏的地方。正确做法是先模拟走一步再判断走完后本方是否仍然处于被将军状态如果是这一步就不能发出去。int findKing(const BoardState board, int side) { int kingType (side 0) ? RED_KING : BLACK_KING; for (int i 0; i 90; i) { if (board.pieces[i] kingType) return i; } return -1; } bool isInCheck(const BoardState board, int side) { int kingIdx findKing(board, side); if (kingIdx 0) return true; for (int from 0; from 90; from) { int p board.pieces[from]; if (p EMPTY || (p 10) (side 0)) continue; if (stepRuleOk(board, from, kingIdx)) return true; } return false; } bool isMoveLegal(BoardState board, const Move move, int side) { if (board.sideToMove ! side) return false; if (board.pieces[move.from] EMPTY) return false; if (!stepRuleOk(board, move.from, move.to)) return false; BoardState tmp board; tmp.pieces[move.to] tmp.pieces[move.from]; tmp.pieces[move.from] EMPTY; if (isInCheck(tmp, side)) return false; return true; }isMoveLegal()是服务端仲裁的闸门。它先检查轮次再检查基础走法最后把棋盘拷贝一份、走一步、重查自己是不是还在被将军。胜负判断同样是基于这个函数如果当前方没有任何合法走法且isInCheck为真就是被将死如果双方连续 60 回合没有吃子按和棋处理。把这些逻辑全部放入BoardState或独立GameRule类客户端和服务端链接同一个静态库能有效避免两边规则不一致。3. 界面层用 QGraphicsView 做棋盘和棋子而不是重绘 QWidget3.1 为什么选 QGraphicsView交互、层级和局部刷新画棋盘最朴素的做法是继承QWidget重写paintEvent然后用mousePressEvent算格子。这个方案在棋子少时没问题但一旦要做选中高亮、棋子拖拽、走子动画和悔棋回退就会陷入“到处调用update()”的泥潭。QGraphicsView 的每个棋子是一个独立 Item天然支持 Item 选择、堆叠顺序、消息传递和局部刷新。配合setViewportUpdateMode()走子时只更新变化的区域比整个 Widget 重绘干净得多。3.2 棋盘 Item 和棋子 Item把绘制和数据解耦我先画棋盘底图再放棋子 Item。棋盘就是一个QGraphicsItem只负责绘制 10 条横线、9 条竖线、九宫斜线和兵炮标记。棋子的数据放在BoardState里界面上的PieceItem只保存boardIndex和pieceType通过setPos()与棋盘交叉点对齐。class BoardItem : public QGraphicsItem { public: enum { Type UserType 2 }; int type() const override { return Type; } QRectF boundingRect() const override { return QRectF(0, 0, 496, 548); } void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override { painter-setRenderHint(QPainter::Antialiasing); painter-fillRect(boundingRect(), QColor(#EBC786)); painter-setPen(QPen(QColor(0x5A, 0x3A, 0x1A), 1.0)); const qreal margin 40; const qreal cellW 52; const qreal cellH 52; // 10 条横线 for (int row 0; row 10; row) { qreal y margin row * cellH; painter-drawLine(QPointF(margin, y), QPointF(margin cellW * 8, y)); } // 9 条竖线中间河界断开 for (int col 0; col 9; col) { qreal x margin col * cellW; painter-drawLine(QPointF(x, margin), QPointF(x, margin cellH * 4)); painter-drawLine(QPointF(x, margin cellH * 5), QPointF(x, margin cellH * 9)); } // 九宫斜线和炮位兵位标记省略按同样坐标画即可 } };这里我把棋盘的 margin 设在 40格子宽高都是 52所以横线范围是40 8 * 52 456竖线范围是40 9 * 52 508整体 boundingRect 是 496 × 548。注意 boundingRect 要覆盖所有绘制内容如果少了几个像素视图刷新时边缘会被裁掉这是很多人调了半天才发现“棋盘线突然断了一截”的原因。绘制时把所有坐标基于 margin 和格子尺寸计算不要在paint()里写死大数字。棋子 Item 我继承QGraphicsObject因为它能发信号方便在点击和动画结束时和主窗口通信。class PieceItem : public QGraphicsObject { Q_OBJECT public: enum { Type UserType 1 }; int type() const override { return Type; } PieceItem(int boardIndex, int pieceType) : m_boardIndex(boardIndex), m_pieceType(pieceType) { setFlag(QGraphicsItem::ItemIsSelectable); } QRectF boundingRect() const override { return QRectF(-25, -25, 50, 50); } void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override { painter-setRenderHint(QPainter::Antialiasing); QColor border isRed() ? QColor(0xC0, 0x30, 0x30) : QColor(0x20, 0x20, 0x20); painter-setBrush(QColor(0xF7, 0xEF, 0xD8)); painter-setPen(QPen(border, 2)); painter-drawEllipse(-22, -22, 44, 44); painter-setPen(border); painter-setFont(QFont(KaiTi, 16, QFont::Bold)); painter-drawText(QRectF(-20, -20, 40, 40), Qt::AlignCenter, pieceName()); } bool isRed() const { return m_pieceType 10; } int boardIndex() const { return m_boardIndex; } private: int m_boardIndex; int m_pieceType; };棋子 Item 的中心点就是棋盘交叉点坐标所以setPos()时直接传BoardState计算出来的网格坐标不需要在 Item 内部再做偏移。所有棋子都在场景里Item 的 z 值可以用setZValue(m_boardIndex / 90.0)保证叠放顺序稳定避免移动时覆盖其他棋子。3.3 点击选中与走子流程先做点击再做拖拽网络棋局里拖拽其实不是必须的点击选子、再点击落子反而更少误触。我在ChessScene里拦截鼠标事件先判断点到的 Item 是不是PieceItem再判断是不是轮到本方能选的子。void ChessScene::mousePressEvent(QGraphicsSceneMouseEvent* event) { QGraphicsScene::mousePressEvent(event); QGraphicsItem* item itemAt(event-scenePos(), QTransform()); if (!item || item-type() ! PieceItem::Type) return; int idx item-data(Qt::UserRole).toInt(); if (m_selectedIdx 0) { if (m_board.pieces[idx] ! EMPTY (m_board.pieces[idx] 10) (m_side 0)) { m_selectedIdx idx; emit selectionChanged(idx); } } else { Move move { m_selectedIdx, idx, 0, 0 }; emit moveRequested(move); m_selectedIdx -1; } }第一次点击选中本方棋子第二次点击可以是落点也可以是对方棋子moveRequested信号把 Move 交给主窗口。主窗口收到后再决定是走本地棋还是发给服务端。这样界面层不知道网络的存在后续改成单机练习也只用换一个“控制源”。悔棋功能要特别注意单机模式可以直接回退m_history但网络对战中本地悔棋会导致双方局面分叉。正确姿势是客户端发UNDO_REQUEST给对手对手同意后由服务端广播一条“悔棋已同意”指令双方同时回退。服务端也要保留一份完整历史否则掉线重连后没法恢复。4. 网络对战实现TCP 持久连接、二进制协议与服务端仲裁4.1 TCP 还是 UDP回合制对局为什么选 TCP象棋是低频、小包、对顺序敏感的应用。UDP 虽然有广播和低延迟的优势但丢包后需要自己实现重传和排序等于把 TCP 已经做好的事情重新做一遍。局域网联机我用 TCP 长连接如果做公网还需要考虑 NAT 穿透和 ICE那已经超出“中国象棋对战”这个项目合理范围。课程设计和实战项目TCP 足够而且 Qt 的QTcpSocket用起来比QUdpSocket更顺手错误处理也更明确。4.2 协议设计一条走子指令需要包含哪些字段网络协议不用搞得很复杂但一定要把“消息头”和“消息体”分开。我用 5 字节头 变长 bodyenum Cmd { CMD_HELLO 1, CMD_MOVE 2, CMD_MOVE_ACCEPTED 3, CMD_MOVE_REJECTED 4, CMD_FULL_STATE 5, CMD_UNDO_REQUEST 6, CMD_UNDO_RESPONSE 7 }; void appendPacket(QByteArray out, quint8 cmd, quint16 seq, const QByteArray body QByteArray()) { QDataStream ds(out, QIODevice::Append); ds.setByteOrder(QDataStream::LittleEndian); ds quint16(0x5A5A); ds cmd; ds seq; ds.writeRawData(body.constData(), body.size()); }协议头包含三样东西魔数0x5A5A用来识别包是否错位cmd表示命令类型seq是发送端递增的序号。body 按不同的cmd有不同解析方式。比如CMD_MOVE的 body 就是两个quint8分别是起点下标和终点下标CMD_MOVE_REJECTED的 body 是一个quint8的拒绝原因。为什么不直接用QString传“from-to”因为字符串解析太脆万一收到 “12-9” 里的某个字符被粘包吃掉整个包就废了。用定长二进制字段虽然写起来麻烦一点但每个包长度固定粘包问题好处理得多。4.3 连接建立与房间分配先到先选边第三方断开服务端用QTcpServer监听客户端连接成功后先发CMD_HELLO服务端回一个CMD_HELLO并附带分配到的颜色。我先到者为红方后到者为黑方第三个连接直接断开后面如果要加观战再把这些 socket 放入观战列表。class GameServer : public QObject { Q_OBJECT public: GameServer(quint16 port, QObject* parent nullptr) : QObject(parent) { m_server new QTcpServer(this); if (!m_server-listen(QHostAddress::Any, port)) { qWarning() listen failed: m_server-errorString(); return; } connect(m_server, QTcpServer::newConnection, this, GameServer::onNewConnection); } private slots: void onNewConnection() { QTcpSocket* socket m_server-nextPendingConnection(); if (m_redSocket m_blackSocket) { socket-disconnectFromHost(); return; } connect(socket, QTcpSocket::readyRead, this, GameServer::readFromSocket); connect(socket, QTcpSocket::disconnected, socket, QTcpSocket::deleteLater); if (!m_redSocket) { m_redSocket socket; sendHello(socket, 0); } else { m_blackSocket socket; sendHello(socket, 1); } } };QHostAddress::Any表示监听所有网卡地址而不是只监听 127.0.0.1。如果写QHostAddress::LocalHost同一台机器调试没问题但局域网里的另一台电脑怎么都连不上这个问题很隐蔽。调试时建议开两个 Qt Creator 实例一个跑服务端一个跑客户端用127.0.0.1先验证逻辑再用真实 IP 验证跨机器。4.4 服务端仲裁为什么不能让两个客户端各自判断网络象棋最容易翻车的点就是“两个客户端各自校验”。如果 A 端规则写错了B 端规则写对了那么 A 走了一步非法棋B 不认两边棋盘立刻不一致。正确做法是客户端收到走子请求后先把结果乐观显示在本地同时把from和to发给服务端服务端用自己那份权威BoardState判断合法性合法就广播给双方不合法就回一个CMD_MOVE_REJECTED客户端回滚。void GameServer::handleMove(int side, quint8 fromIdx, quint8 toIdx) { if (side ! m_board.sideToMove) { sendReject(side, REASON_WRONG_TURN); return; } Move move { fromIdx, toIdx, 0, 0 }; if (!isMoveLegal(m_board, move, side)) { sendReject(side, REASON_ILLEGAL); return; } m_board.applyMove(move); m_history.append(move); QByteArray pkt; appendPacket(pkt, CMD_MOVE_ACCEPTED, m_seq); QDataStream ds(pkt, QIODevice::Append); ds.setByteOrder(QDataStream::LittleEndian); ds fromIdx toIdx quint8(move.captured); m_redSocket-write(pkt); m_blackSocket-write(pkt); }客户端本地做的所有“预判”都只是为了让操作响应更快真正生效的只有服务端广播的CMD_MOVE_ACCEPTED。如果客户端收到CMD_MOVE_REJECTED就调用board.undoLocalPrediction()把局面恢复到发请求之前。服务端还要把每一手move记录到m_history既用于复盘也用于掉线重连后发送CMD_FULL_STATE重建局面。5. 网络对战常见问题排查连接失败、粘包、不一致和界面卡顿5.1 连接不上的检查顺序防火墙、监听地址和端口现象服务端程序显示“listen success”客户端在另一台电脑上connectToHost一直失败。原因最常见的是 Windows 防火墙拦截了入站端口其次是服务端监听地址写成了QHostAddress::LocalHost只能本机连接。解决先把监听地址改成QHostAddress::Any再用命令行netstat -ano | findstr 6666确认监听在0.0.0.0:6666。如果监听没问题临时在防火墙放行入站端口netsh advfirewall firewall add rule namechess6666 dirin actionallow protocolTCP localport6666。联调完成后记得删掉规则别给电脑留一个永久开放端口。5.2 粘包和半包不要直接 readAll 后一条条处理现象网络好的时候正常网络一波动两个客户端先后进入“乱走”状态服务端收到from90这种越界下标。原因TCP 是字节流不是消息流。一次readyRead信号里可能包含两个完整包也可能只包含半个包。如果直接用socket-readAll()按一整条消息处理解析必然错位。解决每个连接维护一个QHashQTcpSocket*, QByteArray累积缓冲区每次先 append再循环解析完整包不足一个包就等下次信号。void GameServer::readFromSocket() { QTcpSocket* s qobject_castQTcpSocket*(sender()); m_rxBuf[s].append(s-readAll()); while (m_rxBuf[s].size() 5) { QDataStream in(m_rxBuf[s], QIODevice::ReadOnly); in.setByteOrder(QDataStream::LittleEndian); quint16 magic; quint8 cmd; quint16 seq; in magic cmd seq; if (magic ! 0x5A5A) { m_rxBuf[s].clear(); return; } int bodyLen (cmd CMD_MOVE) ? 2 : 0; if (m_rxBuf[s].size() 5 bodyLen) return; quint8 from 0, to 0; if (cmd CMD_MOVE) { in from to; } m_rxBuf[s].remove(0, 5 bodyLen); if (cmd CMD_MOVE) handleMove(s, from, to); } }关键点是先看总长度够不够 5 字节头不够就返回再根据cmd看 body 长度不够也返回。解析成功后用remove(0, 5 bodyLen)把已消费的数据从缓冲区头部删掉再进入下一次循环。这段逻辑写完后我建议专门用一个测试程序每秒连发 1000 个短包压一下能过压测才算过关。5.3 双方棋盘不一致服务端没有仲裁或者回滚缺失现象A 端走了一步炮翻山B 端显示合法B 端走了一步车直线吃子A 端却显示非法。原因两个客户端各自执行一套规则代码服务端只转发消息不判断合法性导致两边规则分叉后没人纠偏。解决把规则判断全部收敛到服务端客户端不自行决定“这步能不能走”。客户端本地可以提前显示但收到CMD_MOVE_REJECTED后必须回滚。再彻底一点服务端每次广播CMD_MOVE_ACCEPTED时不只发 from/to还可以带上走完后的BoardState哈希客户端校验自己局面不一致就请求CMD_FULL_STATE。这个哈希校验属于后悔药加不加看你的时间但服务端权威这一步一定要加。5.4 Qt 高 DPI 和窗口缩放棋盘发虚、点击偏移现象2K 屏上棋盘线条和棋子边缘发虚笔记本 125% 缩放后鼠标点击位置明显偏离棋子。原因Qt 5 默认没有开启高 DPI 缩放QGraphicsView虽然用 Qt 坐标系但窗口缩放后mapToScene和物理像素映射不一致。解决Qt 5 在main()最开头加两行QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);Qt 6 默认开启高 DPI不需要再加。另外不要在mousePressEvent里用event-pos()直接反推棋盘下标应该统一走view-mapToScene()再判断坐标否则缩放一变就全线翻车。5.5 QGraphicsView 更新闪烁和 CPU 占用现象走一步棋子整个棋盘闪一下或者开着窗口不动CPU 占用 20%。原因QGraphicsView默认是FullViewportUpdate每次场景变化都重绘整个视口。还有很多人为了刷新棋子起一个QTimer每 30 毫秒调用scene-update()这是低效做法。解决设置view-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate);并加setOptimizationFlag(QGraphicsView::DontSavePainterState);。刷新时只对变化的那颗棋子调用item-update()不要做全场景更新。走子动画尽量用QVariantAnimation驱动setPos()不要在动画循环里反复创建临时 Item。6. 进阶棋谱回放、观战通道和 Qt 版本迁移注意6.1 把 history 导出成 JSON 棋谱服务端已经记录了m_history把它导出成 JSON 很简单。每个 move 只存起点、终点、棋子和被吃棋子不要存中间棋盘快照否则文件体积大又没法悔棋。QJsonArray GameRecorder::toJson(const QVectorMove moves) { QJsonArray arr; for (const Move m : moves) { QJsonObject obj; obj.insert(from, m.from); obj.insert(to, m.to); obj.insert(piece, m.piece); obj.insert(captured, m.captured); arr.append(obj); } return arr; }回放时从BoardState::reset()开始逐条applyMove再用QTimer控制每步间隔。这样做复盘文件可读也能直接在两个客户端之间传输。6.2 观战与聊天在现有协议上扩展 cmd观战者本质是第三个连接服务端收到CMD_HELLO时发现已有红黑双方就把它放进m_viewers列表然后发一包CMD_FULL_STATE给它完整局面。之后的每次CMD_MOVE_ACCEPTED都额外广播给观战者。聊天也可以复用同一条 TCP 连接只是 body 里先放一个quint16字符串长度再放 UTF-8 数据。不要为聊天单独开一条 socket公网环境多一条连接就多一分连接失败风险。6.3 Qt 版本选择5.14.2 还是 6.x如果你现在从零开始用 Qt 5.15 或 Qt 6.x 都行。Qt 5.14.2 和 5.15.2 的离线安装包可以从清华镜像下载比在官方在线安装器里卡进度省心得多。Qt 6 里Qt::AA_EnableHighDpiScaling不再需要QMouseEvent::globalPos()被移除QGraphicsView的接口基本没变。项目里尽量不要同时混用 Qt 5 和 Qt 6 的 API选定一个版本后把QDesktopWidget、QRegExp这类旧接口一次性换掉省得后面维护时来回踩版本坑。做这个项目我最大的教训是不要先把界面写到一半才补网络同步。我第一版把走法合法性放在客户端两个客户端各自判断结果出现 A 觉得将军了、B 不认的尴尬局面被同学用“服务端仲裁”一句话点醒后才改。如果你打算自己写一个“基于 QT 的中国象棋网络对战”把规则内核和网络协议先跑通再把界面套上去三周时间足够。希望帮到你。本文还有配套的精品资源点击获取