QT中国象棋网络对战实战:棋盘建模、TCP协议与同步机制详解
发布时间:2026/10/1 3:27:44来源:尧图网络
简介基于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; QListChessItem* 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否则第三个人连上来就把前两个人顶掉了。用QHashint, 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且acceptedtrue时双方执行相同的状态迁移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操作posQPropertyAnimation* 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::ReuseAddressHintm_server-listen(QHostAddress::Any, PORT);如果要强制复用需要先设置setSocketOption(QAbstractSocket::LowDelayOption, 1)并在listen之前调用m_server-close()清残留。真正定位问题时用netstat -ano | findstr 8888Windows或lsof -i:8888Linux查端口占用进程这一步能排除大量“以为代码错了”的时间。在QT客户端这边连接失败的表现是errorOccurred信号一定要连这个信号打日志否则你会对着connectToHost发呆。5.5 双端规则不一致我改了我自己的棋对端不认现象走棋动画正常播放但对端棋盘纹丝不动。查日志发现move_result返回acceptedfalse。原因双方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; // 被吃掉的棋子完整信息用于恢复 }; QStackMoveRecord 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项目先从本地双人对战跑通规则再往上加网络层每一步都能独立验证会比一次性写完全部代码好得多——希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网