Qt中国象棋网络对战:零云服务的轻量级实时状态同步方案
2026/9/16 18:49:43 网站建设 项目流程

简介:这是一份面向C++与QT开发初学者及进阶者的网络对战游戏实战项目,聚焦TCP网络通信、多线程并发与跨平台GUI开发三大核心能力,帮助开发者掌握在线棋类应用从服务端架构到客户端交互的完整实现路径。资源包共21个文件,含10个cpp源文件(如NetGame.cpp、SingleGame.cpp、Board.cpp等,承载网络连接、游戏逻辑与界面控制)、9个h头文件(定义类接口与数据结构)、1个Qt工程配置文件(Chess.pro)及1个用户配置文件(.user),整体仅16KB,轻量精炼,便于快速导入与调试。已有407人学习下载。读者可直接获取一套结构清晰、模块职责分明的QT中国象棋网络对战源码:服务端基于QTcpServer+QThread实现多客户端并发接入,客户端集成棋盘渲染、落子校验、状态同步与断线重连机制,所有代码均采用标准C++编写,辅以Qt信号槽完成线程安全通信,是理解网络编程与GUI协同开发的优质教学范例。

1. 为什么用 Qt 做中国象棋网络对战,不是“又一个桌面游戏”,而是解决真实协作场景的轻量级方案

你可能见过很多「Qt 象棋」项目,但多数停在单机走子、UI 美化或 Qt Designer 拖拽练习层面。真正能跑通局域网/公网对战、支持断线重连、不依赖第三方服务端、且代码结构清晰可维护的完整实现,其实非常稀缺。这不是炫技——它对应着一类典型需求:高校课程设计需验证网络通信与状态同步逻辑;嵌入式团队想在 ARM Linux 设备上部署低资源消耗的双人策略交互界面;甚至远程协作教学中,教师需要一个无 Web 依赖、启动即用、可本地调试的棋类协同原型。Qt 的优势在此刻凸显:QPainter 提供像素级棋盘绘制控制,QTcpSocket + QThread 可构建稳定可靠的点对点或客户端-服务器模型,而 QMetaObject::invokeMethod 配合信号槽机制,天然适配「落子→序列化→发送→解析→UI 更新」这一闭环。本文不讲 Qt 安装或基础绘图 API,只聚焦「如何让两个独立运行的 Qt 进程,在无中间云服务前提下完成实时、可回溯、带校验的中国象棋对战」——从协议设计到线程安全落子,从坐标映射到网络异常降级处理,全部可抄、可调、可 debug。

2. 用 QTcpSocket 构建可靠对战连接:客户端与服务器角色分离及心跳保活机制

中国象棋网络对战的本质是状态同步,而非单纯消息转发。若仅靠write()发送字符串,极易因粘包、丢包或乱序导致双方棋盘不一致。因此,必须定义二进制协议,并在传输层建立连接可靠性保障。Qt 提供的QTcpSocket是最直接的选择,它比QUdpSocket更适合需要强一致性的棋类场景——毕竟少一次落子确认,整局就可能错位。

2.1 协议设计:固定头+变长体的二进制帧格式

我们采用 8 字节固定头部 + 可变长度数据体的结构,避免文本协议的解析歧义和空格干扰:

字段长度(字节)含义示例值
magic2标识符,固定为0x4343('CC',Chess Code)0x43 0x43
version1协议版本,当前为10x01
type1消息类型:0x01=落子请求,0x02=落子确认,0x03=悔棋申请,0x04=游戏结束0x01
seq2序列号,用于去重与乱序检测0x0001
payload_len2后续 payload 字节数0x0006

payload 部分根据type解析:

  • type=0x01(落子):6 字节 →from_x(1) from_y(1) to_x(1) to_y(1) piece_id(1) side(1)
  • type=0x02(确认):2 字节 →seq_ack(2),表示已成功应用seq对应的落子

提示:不用 JSON 或 Protobuf 是因嵌入式或教学环境常禁用动态链接库;纯 Qt 自带QDataStream已足够高效,且可跨平台二进制兼容。

2.2 服务端监听与客户端连接管理

服务端使用QTcpServer监听指定端口,但仅接受一个客户端连接(双人对战本质是 1v1),避免复杂会话管理:

// chessserver.h class ChessServer : public QObject { Q_OBJECT public: explicit ChessServer(quint16 port, QObject *parent = nullptr); void start(); private slots: void onNewConnection(); void onClientDisconnected(); private: QTcpServer *m_server; QTcpSocket *m_clientSocket; // 唯一客户端 quint16 m_port; };
// chessserver.cpp ChessServer::ChessServer(quint16 port, QObject *parent) : QObject(parent), m_port(port) {} void ChessServer::start() { m_server = new QTcpServer(this); if (!m_server->listen(QHostAddress::Any, m_port)) { qWarning() << "Failed to bind server to port" << m_port; return; } connect(m_server, &QTcpServer::newConnection, this, &ChessServer::onNewConnection); } void ChessServer::onNewConnection() { if (m_clientSocket) { // 已有连接,拒绝新连接 QTcpSocket *reject = m_server->nextPendingConnection(); reject->close(); return; } m_clientSocket = m_server->nextPendingConnection(); connect(m_clientSocket, &QTcpSocket::disconnected, this, &ChessServer::onClientDisconnected); connect(m_clientSocket, &QTcpSocket::readyRead, this, &ChessServer::handleClientData); qDebug() << "Client connected from" << m_clientSocket->peerAddress().toString(); }

客户端则主动连接,并在连接后立即发送握手包(含玩家身份标识):

// chessclient.cpp void ChessClient::connectToServer(const QString &host, quint16 port) { m_socket->connectToHost(host, port); if (!m_socket->waitForConnected(3000)) { qWarning() << "Connection timeout"; return; } // 发送握手:type=0x00,payload="RED" or "BLACK" QByteArray handshake; QDataStream out(&handshake, QIODevice::WriteOnly); out.setByteOrder(QDataStream::BigEndian); out << quint16(0x4343) << quint8(1) << quint8(0x00) << quint16(0) << quint16(3); out.writeRawData("RED", 3); // 假设本方为红方 m_socket->write(handshake); }

2.3 心跳保活与断线检测

TCP 连接空闲时可能被中间设备静默关闭。我们在服务端每 15 秒向客户端发送type=0x05(心跳)包,客户端收到后必须回复type=0x06(心跳响应)。若连续 3 次未收到响应,则判定断线:

// 服务端定时器触发 void ChessServer::sendHeartbeat() { if (!m_clientSocket || m_clientSocket->state() != QAbstractSocket::ConnectedState) return; QByteArray hb; QDataStream out(&hb, QIODevice::WriteOnly); out.setByteOrder(QDataStream::BigEndian); out << quint16(0x4343) << quint8(1) << quint8(0x05) << quint16(0) << quint16(0); m_clientSocket->write(hb); m_heartbeatCount++; } // 客户端收到心跳后立即响应 void ChessClient::handleServerData() { while (m_socket->bytesAvailable() >= 8) { QByteArray header = m_socket->read(8); QDataStream in(&header, QIODevice::ReadOnly); in.setByteOrder(QDataStream::BigEndian); quint16 magic, payloadLen; quint8 version, type, seq[2]; in >> magic >> version >> type >> seq[0] >> seq[1] >> payloadLen; if (magic != 0x4343) continue; if (type == 0x05) { // 心跳请求 QByteArray resp; QDataStream out(&resp, QIODevice::WriteOnly); out.setByteOrder(QDataStream::BigEndian); out << quint16(0x4343) << quint8(1) << quint8(0x06) << quint16(0) << quint16(0); m_socket->write(resp); m_lastHeartbeatReply = QDateTime::currentMSecsSinceEpoch(); } else if (type == 0x06) { m_lastHeartbeatReply = QDateTime::currentMSecsSinceEpoch(); } // ... 其他 type 处理 } }

注意QTimer必须设置为Qt::PreciseTimer并启用setSingleShot(false),否则在高负载下可能丢失心跳。同时,m_lastHeartbeatReply需在主线程安全访问,建议用QAtomicInteger<qint64>封装。

3. 基于 QPainter 的棋盘渲染与坐标映射:支持缩放、拖拽与高 DPI 屏幕

Qt 的QPainter是实现中国象棋 UI 的核心,它比QGraphicsView更轻量、更可控,尤其适合需要精确像素定位的棋盘格线与棋子绘制。关键不在“画出来”,而在“画得准”——坐标映射必须严格对应规则中的行列编号(红方底行是第 0 行,黑方底行是第 9 行),且支持窗口缩放、DPI 自适应、鼠标点击反查坐标。

3.1 棋盘网格与坐标系定义

中国象棋标准棋盘为 10 行 × 9 列,行号 0–9(红方在下,y=0 为红方底线),列号 0–8(x=0 为左边界)。我们定义逻辑坐标系(Logical Coordinate System)与设备坐标系(Device Coordinate System)分离:

  • 逻辑坐标:(x, y)[0,8] × [0,9],整数,代表棋盘格位置
  • 设备坐标:通过scaleFactor映射为像素,例如cellWidth = 60,cellHeight = 60,则(x,y)对应矩形(x*cellWidth, y*cellHeight, cellWidth, cellHeight)
// chessboardwidget.h class ChessBoardWidget : public QWidget { Q_OBJECT public: explicit ChessBoardWidget(QWidget *parent = nullptr); void setScaleFactor(qreal factor); // 支持缩放 void setOffset(int dx, int dy); // 支持拖拽偏移 QPoint logicalPosFromDevice(const QPoint &devicePos) const; protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; private: qreal m_scaleFactor = 1.0; int m_offsetX = 0, m_offsetY = 0; static constexpr int kCellWidth = 60; static constexpr int kCellHeight = 60; static constexpr int kBoardWidth = 9 * kCellWidth; static constexpr int kBoardHeight = 10 * kCellHeight; };

3.2 高 DPI 适配与抗锯齿绘制

在 macOS 或 Windows 高分屏上,devicePixelRatio()可能为 2 或更高。若直接按逻辑尺寸绘制,棋盘会模糊。正确做法是:获取QPainter::device()->logicalDpiX(),并据此调整cellWidth计算:

// chessboardwidget.cpp void ChessBoardWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); const qreal dpr = devicePixelRatioF(); const int actualCellW = qRound(kCellWidth * dpr * m_scaleFactor); const int actualCellH = qRound(kCellHeight * dpr * m_scaleFactor); // 绘制背景(米黄色) painter.fillRect(rect(), QColor(255, 248, 220)); // 绘制横线(10 条) for (int y = 0; y <= 9; ++y) { int py = y * actualCellH + m_offsetY; painter.drawLine(0, py, width(), py); } // 绘制竖线(9 条) for (int x = 0; x <= 8; ++x) { int px = x * actualCellW + m_offsetX; painter.drawLine(px, 0, px, height()); } // 绘制河界(第 4–5 行之间) int riverY = 4 * actualCellH + m_offsetY; painter.setPen(QPen(Qt::blue, 2)); painter.drawLine(0, riverY, width(), riverY); painter.setPen(Qt::black); // 绘制九宫格(红方:3–5 列,0–2 行;黑方:3–5 列,7–9 行) auto drawPalace = [&](int x0, int y0) { int px0 = x0 * actualCellW + m_offsetX; int py0 = y0 * actualCellH + m_offsetY; painter.drawLine(px0, py0, px0 + 2 * actualCellW, py0); painter.drawLine(px0, py0 + 2 * actualCellH, px0 + 2 * actualCellW, py0 + 2 * actualCellH); painter.drawLine(px0, py0, px0, py0 + 2 * actualCellH); painter.drawLine(px0 + 2 * actualCellW, py0, px0 + 2 * actualCellW, py0 + 2 * actualCellH); // 斜线 painter.drawLine(px0, py0, px0 + 2 * actualCellW, py0 + 2 * actualCellH); painter.drawLine(px0 + 2 * actualCellW, py0, px0, py0 + 2 * actualCellH); }; drawPalace(3, 0); // 红方九宫 drawPalace(3, 7); // 黑方九宫 // 绘制棋子(此处简化,实际需遍历棋盘 state) for (int y = 0; y < 10; ++y) { for (int x = 0; x < 9; ++x) { if (m_board[y][x]) { drawPiece(painter, x, y, m_board[y][x], actualCellW, actualCellH); } } } }

3.3 鼠标点击→逻辑坐标的精准反查

用户点击屏幕任意位置,需返回(x,y)逻辑坐标。关键在于:必须用当前缩放+偏移后的实际像素尺寸反算,且需处理边界容错(如点击格线交点应归属最近格):

QPoint ChessBoardWidget::logicalPosFromDevice(const QPoint &devicePos) const { const qreal dpr = devicePixelRatioF(); const int actualCellW = qRound(kCellWidth * dpr * m_scaleFactor); const int actualCellH = qRound(kCellHeight * dpr * m_scaleFactor); // 减去偏移,再除以单元格尺寸,得到浮点逻辑坐标 qreal lx = (devicePos.x() - m_offsetX) / static_cast<qreal>(actualCellW); qreal ly = (devicePos.y() - m_offsetY) / static_cast<qreal>(actualCellH); // 四舍五入到最近整数格,但限制在 [0,8]×[0,9] int x = qBound(0, qRound(lx), 8); int y = qBound(0, qRound(ly), 9); // 额外容错:若点击位置离格线 < 5px,强制吸附到该格(提升体验) qreal dx = qAbs(lx - x); qreal dy = qAbs(ly - y); if (dx < 0.1 && dy < 0.1) { return {x, y}; } return {-1, -1}; // 无效点击 } void ChessBoardWidget::mousePressEvent(QMouseEvent *event) { if (event->button() != Qt::LeftButton) return; QPoint logical = logicalPosFromDevice(event->pos()); if (logical.x() >= 0 && logical.y() >= 0) { emit clicked(logical.x(), logical.y()); // 信号通知业务逻辑 } }

提示qBoundqMin/qMax更安全,避免越界;qRound使用qreal版本防止整数截断误差;所有dpr相关计算必须在paintEvent和坐标反查中保持一致。

4. 网络落子同步与线程安全状态更新:QMutexGuard 与事件队列驱动 UI

网络对战最易出错的环节,是「网络线程收到落子指令」与「主线程刷新 UI」之间的竞态。若直接在QTcpSocket::readyRead槽函数中修改棋盘数组并调用update(),极可能导致QPainter在绘制中途被数据修改打断,引发崩溃或画面撕裂。Qt 推荐方案是:网络线程只做解析与校验,将有效操作封装为QEvent投递至主线程处理。

4.1 自定义事件类型与落子操作封装

定义ChessMoveEvent继承QEvent,携带完整落子信息:

// chessevent.h #include <QEvent> struct ChessMove { int fromX, fromY, toX, toY; quint8 pieceId; bool isRed; // true=红方,false=黑方 }; class ChessMoveEvent : public QEvent { public: static const QEvent::Type kType = static_cast<QEvent::Type>(QEvent::User + 100); explicit ChessMoveEvent(const ChessMove &move) : QEvent(kType), m_move(move) {} ChessMove move() const { return m_move; } private: ChessMove m_move; };

4.2 网络线程解析后投递事件

ChessClient::handleServerData()中,当解析出type=0x01落子包后,不再直接调用applyMove(),而是创建事件并QApplication::postEvent()

// chessclient.cpp void ChessClient::handleServerData() { // ... 解析 header 和 payload ... if (type == 0x01 && payloadLen == 6) { QByteArray payload = m_socket->read(6); ChessMove move; move.fromX = static_cast<uchar>(payload[0]); move.fromY = static_cast<uchar>(payload[1]); move.toX = static_cast<uchar>(payload[2]); move.toY = static_cast<uchar>(payload[3]); move.pieceId = static_cast<uchar>(payload[4]); move.isRed = (payload[5] == 1); // 校验:是否在合法范围内?是否为对方回合? if (isValidMove(move) && isOpponentTurn(move.isRed)) { QCoreApplication::postEvent( m_boardWidget, new ChessMoveEvent(move) ); } } }

4.3 主线程事件过滤与线程安全更新

ChessBoardWidget重写customEvent(),并在其中加锁更新内部状态:

// chessboardwidget.cpp bool ChessBoardWidget::event(QEvent *event) { if (event->type() == ChessMoveEvent::kType) { ChessMoveEvent *e = static_cast<ChessMoveEvent*>(event); ChessMove move = e->move(); // 使用 QMutexGuard 确保棋盘数组读写原子性 QMutexLocker locker(&m_boardMutex); applyMoveToBoard(move); // 内部修改 m_board[y][x] update(); // 触发重绘 emit moveApplied(move); // 通知上层(如播放音效、记录日志) return true; } return QWidget::event(event); } void ChessBoardWidget::applyMoveToBoard(const ChessMove &move) { // 1. 清空目标位置 m_board[move.toY][move.toX] = 0; // 2. 移动棋子(此处简化,实际需判断吃子、将帅照面等) m_board[move.toY][move.toX] = m_board[move.fromY][move.fromX]; m_board[move.fromY][move.fromX] = 0; // 3. 更新回合状态 m_currentSide = !move.isRed; }

注意QMutexLocker构造即加锁,析构自动解锁,避免忘记 unlock;applyMoveToBoard()不应包含任何耗时操作(如网络请求、文件 IO),否则会阻塞 UI 线程;所有 UI 更新(update()repaint())必须在主线程调用。

5. 实战调试技巧:用 Wireshark 抓包验证协议、QLoggingCategory 分级日志、断线重连状态机

当网络对战出现“对方看不到我的落子”或“自己棋盘突然错位”时,问题往往不在 UI 渲染,而在协议解析或状态同步逻辑。以下三个技巧可快速定位 80% 的线上问题。

5.1 Wireshark 过滤二进制协议流量

在开发机上启动 Wireshark,设置捕获过滤器tcp.port == 8888(假设使用 8888 端口),然后添加显示过滤器匹配我们的 Magic 字段tcp.payload matches "CC"。点击「Follow → TCP Stream」即可看到原始十六进制流:

0000 43 43 01 01 00 01 00 06 00 00 00 04 04 00 01 01 CC..............

对照协议表:43 43→ magic,01→ version,01→ type(落子),00 01→ seq=1,00 06→ payload_len=6,后续00 00 00 04 04 00from_x=0, from_y=0, to_x=4, to_y=4, piece_id=1, side=1。若发现payload_len与实际不符,说明发送端序列化错误;若seq乱序,说明网络层未启用TCP_NODELAY(需在QTcpSocket上调用setSocketOption(QAbstractSocket::LowDelayOption, 1))。

5.2 QLoggingCategory 实现模块化日志分级

为避免qDebug()输出淹没关键信息,按模块定义日志类别:

// main.cpp Q_LOGGING_CATEGORY(lcNetwork, "chess.network") Q_LOGGING_CATEGORY(lcBoard, "chess.board") Q_LOGGING_CATEGORY(lcProtocol, "chess.protocol") int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); qSetMessagePattern("[%{time yyyy-MM-dd hh:mm:ss.zzz}] %{category} %{function}:%{line} - %{message}"); // 只输出 network 和 protocol 的 warning 及以上 QLoggingCategory::setFilterRules("chess.network.debug=false\nchess.protocol.debug=false"); // ... }

在网络模块中使用:

qCDebug(lcNetwork) << "Connected to" << host << port; qCWarning(lcProtocol) << "Invalid magic bytes:" << magic;

5.3 断线重连状态机:从“重连中”到“已同步”

简单socket->connectToHost()循环重试会导致雪崩。我们用QStateMachine实现有限状态机:

状态进入动作超时动作成功转移
Connectingsocket->connectToHost()5s 后转Reconnectingconnected()Syncing
ReconnectingQTimer::singleShot(3000, this, &reconnect)connected()Syncing
Syncing发送type=0x07(同步请求),等待对方发全量棋盘快照8s 未收到 →Reconnecting收到快照 →Playing
// reconnectionmachine.h class ReconnectionMachine : public QStateMachine { Q_OBJECT public: explicit ReconnectionMachine(QTcpSocket *socket, QObject *parent = nullptr); private: QTcpSocket *m_socket; QState *m_connectingState; QState *m_reconnectingState; QState *m_syncingState; };

此状态机确保:断线后不会疯狂重连,且重连成功后必先同步棋盘状态再允许落子,彻底杜绝“开局就少一个车”的诡异现象。

提示QStateMachineaddDefaultAnimation()可绑定QPropertyAnimation,让 UI 在“重连中”状态显示脉冲动画,提升用户体验。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询