尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Qt中国象棋网络对战实战:从规则引擎到TCP通信的完整开发路线

Qt中国象棋网络对战实战:从规则引擎到TCP通信的完整开发路线 简介基于Qt框架与C语言实现的中国象棋网络对战完整工程源码面向具备一定C基础、希望系统学习Qt网络编程和多线程并发开发的开发者。项目采用客户端/服务器架构服务端基于TCP协议建立可靠通信通过QTcpServer监听连接、QTcpSocket收发数据并利用多线程为多个玩家提供并发服务同时完整实现棋盘绘制、走子规则、吃子判断、胜负判定等游戏逻辑。压缩包共21个文件以10个cpp源码和9个h头文件为主体模块划分清晰涵盖网络通信、游戏控制、象棋规则、界面选择、棋盘绘制等功能另含pro工程配置与user用户配置整包仅16KB代码精简、便于阅读和二次改造。网络通信模块包含完整的收发处理与异常断开处理多线程部分则展示如何通过线程管理维持多玩家对战状态。已有408人学习浏览适合作为Qt网络编程与多线程实战的参考案例也可帮助开发者在学习后快速搭建属于自己的联网棋类游戏原型。1. 基于Qt的中国象棋网络对战从单机到联机的完整落地路线周末朋友想隔着半个城市下一盘象棋网页版小游戏要么广告多要么操作粘手我最后用不到两周的空闲时间用Qt把中国象棋的网络对战在开发机上完整跑通。这套方案要解决的不只是“画一个棋盘”而是把中国象棋的规则引擎、QPainter绘制、QTcpSocket通信串成一个可以稳定复现的开发闭环。整条路线适合两类人一类是C基础扎实、想拿一个完整Qt项目练手而不是天天写登录框的另一类是把中国象棋网络对战当课设或毕设需要把架构讲清楚、能现场演示的。最反直觉的一点是网络通信并不是这个项目最大的黑匣子真正容易翻车的地方在规则引擎的边界判断比如马脚、象眼、将帅照面以及消息拆包时莫名其妙的粘包。下面按环境、规则、网络、排错的顺序一层层往下拆。2. 搭建Qt环境与项目骨架从5.15.2到第一个能跑的棋盘窗口2.1 为什么选Qt 5.15.2LTS、编译器与离线安装包做Qt项目第一步不是新建工程是先把工具链选对。我一般用Qt 5.15.2搭配Qt Creator不追新版本。原因是5.15.2是5系的LTS社区里的中文资料、各类开源项目大多基于这个版本遇到问题搜起来最省时间。Qt 6确实更新网络模块和QPainter的基础API变化不大但按旧教程走很容易踩到模块拆分导致的编译错。选型上不用纠结性能人和人下棋对帧率没有任何要求稳定易查错才是第一位的。安装时注意编译器阵营Windows上常见MinGW 64-bit和MSVC 2019两套。MinGW安装简单、发布时不用带VS运行库MSVC在性能调试和某些第三方库对接上更强。我建议第一版用MinGW把业务跑通后再考虑MSVC。这里有一条血泪经验项目中途换编译器构建目录不清理Qt会抛“cannot mix incompatible qt library (version ex50601)”这类让人一头雾水的报错后面的避坑章节再细说。下载走官网在线安装器最稳网络不好可以用离线安装包。装的时候把暂时用不到的模块都去掉只保留Qt Widgets和Qt Network安装体积小、后续编译也快。装完在“工具→选项→Kits”里确认编译器路径这是新手最容易忽略的一步——Kit和编译器绑定错了构建出来的程序在别的机器上就是起不来。拿到能跑的空窗口之后再进入界面搭建。2.2 用Qt Designer搭出主界面棋盘控件的提升与状态栏新建项目时选“Qt Widgets Application”基类选QMainWindow构建系统用qmake不需要上CMake。在Qt Designer里把默认中央widget替换成一个普通QWidget右侧放两个QPushButton开始对局、悔棋、一个QLabel显示当前状态、一个QListWidget滚动显示下棋记录。然后把中间的QWidget右键“提升为”BoardView类这一步做完Designer里只能看到空白占位运行时paintEvent自动接管绘制。这种做法的好处是界面布局和业务逻辑分离调整按钮位置只动.ui文件不必改C代码。我习惯把重绘相关的类放在单独文件里而不是堆在MainWindow里。BoardView继承自QWidget并持有棋盘的指针Board*视图层只负责把棋盘状态画出来不关心走子规则。这样后面把QWidget换成QOpenGLWidget做加速业务层一行不用动。// BoardView.h 关键部分 class BoardView : public QWidget { Q_OBJECT public: explicit BoardView(QWidget* parent nullptr); void setBoard(Board* b) { board b; update(); } protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; private: Board* board nullptr; int margin 30; // 棋盘外侧留白 int cellW 50; // 每格水平像素 int cellH 50; // 每格垂直像素 };这段代码里margin、cellW、cellH是绘图的三要素留白保证棋子不会贴到窗口边缘格宽和格高决定整个棋盘的缩放比例。如果窗口拉大后棋子仍然挤在左上角多半是这三个值没有随窗口尺寸重新计算。setBoard里调用update()触发重绘这是Qt视图层最常用的通知手段不要在外部直接调paintEvent那会让绘制上下文错乱窗口最小化再恢复后棋盘会变成一片空白。2.3 用QPainter绘制棋盘坐标换算与棋子落点中国象棋棋盘是9条竖线、10条横线棋子落在交叉点上不是跳棋那种格子中心。所以绘图的第一步是把逻辑坐标(x, y)换算成像素坐标。我用左上角的margin作为原点偏移水平方向left x * cellW垂直方向top y * cellH这样棋盘内部占位是8个格宽、9个格高四边正好留出一圈空隙。整个绘制过程放在paintEvent里由Qt的绘制系统统一调度。void BoardView::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); const int left margin, top margin; const int bw cellW * 8, bh cellH * 9; // 竖线共9条两端对齐 for (int i 0; i 9; i) { painter.drawLine(left i * cellW, top, left i * cellW, top bh); } // 横线共10条 for (int j 0; j 10; j) { painter.drawLine(left, top j * cellH, left bw, top j * cellH); } // 九宫斜线上边框外与第2、8、9条横线对应的斜线 painter.drawLine(left 3 * cellW, top, left 5 * cellW, top 2 * cellH); painter.drawLine(left 5 * cellW, top, left 3 * cellW, top 2 * cellH); painter.drawLine(left 3 * cellW, top 7 * cellH, left 5 * cellW, top 9 * cellH); painter.drawLine(left 5 * cellW, top 7 * cellH, left 3 * cellW, top 9 * cellH); // 河界文字 painter.drawText(QRect(left, top 4 * cellH, cellW * 8, cellH), Qt::AlignCenter, QStringLiteral(楚 河 汉 界)); // 遍历棋盘画棋子 for (int x 0; x 9; x) { for (int y 0; y 10; y) { int id board-at(x, y); if (id 0) continue; QPointF center(left x * cellW, top y * cellH); drawPiece(painter, center, id); } } }这里有几个参数值得说清楚。cellW和cellH初始都是50窗口默认大小约510x560刚好放下。九宫斜线的终点用top 2 * cellH而不是top 2 * cellH - 1是为了让斜线端点恰好落在交叉点上避免斜线出头这个细节直接影响棋盘观感。画棋子时用QPointF作为中心点后续要加选中高亮只需在中心点周围画一个半透明圆不用动坐标换算。drawPiece里用QRadialGradient画圆形底座红方底色偏亮、文字用黑字黑方底色偏暗、文字用红字这和多数棋谱软件一致。如果发现文字偏了常见原因不是坐标系算错而是QRectF没有为“车”“马”“炮”这种单字和“兵”“卒”这种单字统一留足内边距建议全部按双字宽度画。3. 中国象棋规则引擎走法、将军与胜负怎么落到代码3.1 棋盘建模二维数组加棋子对象很多初学者一上来就把棋子状态写死在界面控件里结果每次走子都要遍历所有按钮悔棋和棋谱根本没法做。我的做法是数据与界面分离Board类维护两个表一个是int grid[9][10]记录交叉点上是第几号棋子0表示空一个是Piece pieces[32]记录每个棋子的类型、归属和当前位置。界面只读grid来画图规则引擎只操作grid和pieces双方通过信号槽通知视图刷新。// board.h #pragma once #include QPoint enum Player { NONE, RED, BLACK }; enum PieceType { NO_PIECE, KING, ADVISOR, ELEPHANT, HORSE, CHARIOT, CANNON, PAWN }; struct Piece { Player owner NONE; PieceType type NO_PIECE; QPoint pos; // x:0-8, y:0-9 }; class Board { public: Board() { reset(); } void reset(); // 摆好初始局面 int at(int x, int y) const { return grid[x][y]; } bool isLegalMove(int fx, int fy, int tx, int ty) const; void makeMove(int fx, int fy, int tx, int ty); Player current() const { return turn; } private: bool canMove(int id, const QPoint to) const; bool isChecked(Player side) const; Piece pieces[32]; int grid[9][10] {}; Player turn RED; };这里为什么用9x10二维数组不用位棋盘中国象棋的棋子功能差异大位棋盘的加速效果主要靠搜索算法的高频调用体现而人人对战的规则判断每秒撑死几十次用二维数组最直观调试时一眼能看出坐标对不对。pieces用固定32长度是因为棋局中最多同时存在32个棋子被吃掉的棋子type置为NO_PIECE、owner保留这样悔棋回滚时还能恢复原始信息。reset函数里按传统棋谱顺序摆子红方从y9到y5黑方从y0到y4方向别搞反否则后面所有走法判断全乱。3.2 合法走法与特殊规则马脚、象眼、将帅照面规则引擎的核心在canMove函数。先判断通用条件目标点越界直接返回false目标点上如果是己方棋子直接返回false将帅不能走出九宫。接着按棋子类型分支。最容易写错的是马脚、象眼和将帅照面三条这里单独展开。马走“日”有两种跨法横向两格纵向一格或纵向两格横向一格马脚在长边中间那一格。象走“田”象眼是田字中心且象不能过河。将帅照面则是一条隐藏规则红帅和黑将在同一条竖线上且中间没有任何棋子时视为直接照面这步棋不允许走。bool Board::canMove(int id, const QPoint to) const { const Piece p pieces[id]; QPoint from p.pos; int dx to.x() - from.x(); int dy to.y() - from.y(); // 越界与目标为同色棋子 if (to.x() 0 || to.x() 8 || to.y() 0 || to.y() 9) return false; int tid at(to.x(), to.y()); if (tid ! 0 pieces[tid].owner p.owner) return false; switch (p.type) { case HORSE: if (qAbs(dx) 2 qAbs(dy) 1) { if (grid[from.x() dx / 2][from.y()] ! 0) return false; } else if (qAbs(dx) 1 qAbs(dy) 2) { if (grid[from.x()][from.y() dy / 2] ! 0) return false; } else { return false; } break; case ELEPHANT: if (qAbs(dx) ! 2 || qAbs(dy) ! 2) return false; if (grid[from.x() dx / 2][from.y() dy / 2] ! 0) return false; if (p.owner RED to.y() 5) return false; if (p.owner BLACK to.y() 4) return false; break; case PAWN: // 红方从下往上走y减小 if (p.owner RED) { if (dy -1 dx 0) break; if (from.y() 5 qAbs(dx) 1 dy 0) break; return false; } else { if (dy 1 dx 0) break; if (from.y() 4 qAbs(dx) 1 dy 0) break; return false; } default: break; } // 将帅照面 if (p.type KING || tid KING) { bool face false; if (from.x() to.x()) { int step (from.y() to.y()) ? 1 : -1; bool empty true; for (int y from.y() step; y ! to.y(); y step) if (grid[from.x()][y] ! 0) { empty false; break; } if (empty) face true; } if (face) return false; } return true; }这段代码的要点在两个地方。首先是马脚判断dx / 2和dy / 2正好得到马脚坐标偏移不需要额外if分支但很容易把dx2、dy1和dx1、dy2两种跨大步的情况搞混建议先分开写测试全过了再考虑合并。其次是兵卒的过河判断红方从y9往y0走过河线在y4与y5之间红方y4才算过河黑方y5才算过河方向反了会出现在自家半场横走的怪事。车和炮更直白车从起点到终点逐格查空炮需要统计中间棋子数恰好为1才能吃子这两类判断我直接写在default分支里不再展开。3.3 将军与胜负判定用模拟走子闭合成一个循环有了canMove将军判定就很自然某方被将军等价于对方的某一枚棋子能一步走到己方将帅所在位置。把将帅坐标作为目标点传给canMove即可。这里有一个反直觉的点canMove内部虽然已经检查了将帅照面但走一步棋之后有可能出现“对方将军时用己方棋子挡住反而把自己将死”的误判所以最稳妥的做法是走棋之前先备份棋盘模拟走子再调用isChecked判断自己是否被将军如果是这步就不能走。bool Board::isChecked(Player side) const { int king -1; for (int i 0; i 32; i) { if (pieces[i].owner side pieces[i].type KING) { king i; break; } } if (king 0) return true; // 将帅已被吃 QPoint kp pieces[king].pos; for (int i 0; i 32; i) { if (pieces[i].type NO_PIECE) continue; if (pieces[i].owner side) continue; if (canMove(i, kp)) return true; } return false; } bool Board::isLegalMove(int fx, int fy, int tx, int ty) const { int id at(fx, fy); if (id 0 || pieces[id].owner ! turn) return false; if (!canMove(id, QPoint(tx, ty))) return false; // 模拟走子拷贝当前棋盘走完看自己是否被将军 Board snapshot *this; snapshot.makeMove(fx, fy, tx, ty); return !snapshot.isChecked(turn); }胜负检测在isLegalMove外层做。走完之后如果对手无子可走就是困毙判负如果对手将帅已被吃直接判负。我这里没做“长将判负”这类高级规则人人和网对战主要看个输赢规则给得太严反而容易起争执。isLegalMove用“拷贝整个Board再模拟”的方式在9x10棋盘上开销可以忽略但逻辑上换来极大的简化不需要在canMove里特判“这步会不会送将”所有规则统一收敛到isChecked一个函数。这里有一个隐藏前提Board必须能安全拷贝所以成员里不能出现裸指针或需要深拷贝的资源否则默认拷贝会出问题。测这个规则引擎的土办法是从初始局面随机走子每走一步立刻撤销反复跑几万次看有没有越界或崩溃能快速暴露数组下标问题。4. 用QTcpSocket实现局域网对战协议先行代码后写4.1 通信协议设计固定头带长度别让消息没有边界网络对战的第一个决定是做协议。TCP是字节流没有消息边界两次write可能被网络栈合并读一次大消息也可能被拆成多次读。如果只按新行分隔遇到二进制数据就傻眼。因此我坚持“固定头负载”的格式10字节固定头包括4字节魔数、1字节类型、4字节长度后面跟JSON负载。魔数的作用是防止缓存错位后把乱数据当合法包长度字段让接收端知道要攒够多少字节才算一条完整消息。type含义关键负载字段1握手player: red/black2就绪开始roomId3走子fx/fy/tx/ty/seq4悔棋seq5认输无6心跳seq// protocol.h #pragma once #include QByteArray #include QDataStream #include QJsonDocument #include QJsonObject const quint32 MAGIC 0x5848484A; struct NetMessage { quint32 magic; quint8 type; qint32 length; QByteArray payload; }; QByteArray encodeMessage(quint8 type, const QJsonObject obj) { QByteArray payload QJsonDocument(obj).toJson(QJsonDocument::Compact); QByteArray head; QDataStream ds(head, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); ds MAGIC type (qint32)payload.size(); return head payload; } // 拆包从缓冲区中取出所有完整消息剩余的留在buffer里 QVectorNetMessage decodeBuffer(QByteArray buffer) { QVectorNetMessage msgs; while (buffer.size() 10) { QDataStream ds(buffer); ds.setByteOrder(QDataStream::BigEndian); quint32 m; quint8 t; qint32 len; ds m t len; if (m ! MAGIC) { buffer.clear(); break; } if (buffer.size() 10 len) break; // 半包 NetMessage msg; msg.magic m; msg.type t; msg.length len; msg.payload buffer.mid(10, len); msgs.append(msg); buffer.remove(0, 10 len); } return msgs; }这段代码里BigEndian是特意写的确保两端在不同架构的机器上也能读到一致的字段顺序。长度字段用qint32单个消息最大2GB象棋一局的消息撑死几百字节留足余量。decodeBuffer用while循环因为一次read可能收到多条完整消息必须全部取出如果只取一条剩下的就留在缓冲里等下一次readyRead处理不好就会出现“上一步没反应、下一步又一起跳出来”的灵异现象。魔数我是随手写的可以放在协议常量区统一管理。4.2 客户端与服务端最小实现异步信号槽是唯一正解服务端用QTcpServer监听到newConnection后取出QTcpSocket把两个socket存在一个QList里当房间。客户端用QTcpSocket连接服务端IP和端口。关键点是全程不要用waitForConnected、waitForReadyRead这类阻塞函数否则你点一下“连接”按钮主界面就转圈看起来像丢了响应。正确的做法是把网络事件全部接到信号槽上让Qt事件循环驱动而不是让网络阻塞GUI线程。// client.cpp 片段 QTcpSocket* socket new QTcpSocket(this); QByteArray recvBuf; connect(socket, QTcpSocket::connected, this, [this]() { statusLabel-setText(QStringLiteral(已连接等待对手...)); sendMessage(1, { {player, red} }); // 握手 }); connect(socket, QTcpSocket::readyRead, this, [this, socket]() { recvBuf.append(socket-readAll()); QVectorNetMessage msgs decodeBuffer(recvBuf); for (const NetMessage m : msgs) { handleMessage(m); // 按type分发给棋盘和界面 } }); connect(socket, QTcpSocket::errorOccurred, this, [this]() { statusLabel-setText(QStringLiteral(连接失败%1) .arg(socket-errorString())); });connectToHost只是发起连接结果由信号通知这是Qt网络编程与很多其他语言最大的差异。这里有一个习惯性问题不要在lambda里直接堆复杂逻辑handleMessage单独抽函数方便后面加单测。收到消息后再按type分派1是握手确认2是开始对局3是走子4是悔棋5是认输6是心跳。分派函数集中写switch比在readyRead里堆if清爽。端口方面建议用9000以上的高位端口避开常见端口冲突局域网内注意防火墙放行TCP入站规则否则对方连不上这是排查时最容易被忽略的外部因素。4.3 走子同步与本地校验从点击到对方棋盘的完整链路走子的核心流程是本地点击棋子先做一次本地规则校验直接用第3章的isLegalMove通过后把from和to放进JSON发给服务端。服务端校验双方的socket身份确保是轮到走棋的那一方然后把消息原样转发给另一方。对端收到走子消息后直接在自己本地棋盘上执行makeMove并重绘。// server.cpp 片段 void Server::routeMessage(QTcpSocket* from, const NetMessage msg) { if (msg.type ! 3) return; // 简单仲裁只有当前轮到的玩家能走子 if (from ! players[currentIndex]) return; // 转发给另一方 QTcpSocket* other players[1 - currentIndex]; other-write(encodeMessage(3, QJsonDocument::fromJson(msg.payload).object())); currentIndex 1 - currentIndex; // 轮转 }有人问这种服务端没有完整规则引擎会不会有玩家绕过本地校验直接发非法走子。答案是会。如果这是正式比赛项目服务端必须完整跑一套规则引擎做二次校验。但局域网朋友对局互信模型够用实现复杂度低一个数量级。我通常在代码注释里写明“当前服务端只做身份仲裁规则校验依赖对端”以后要做观战或AI托管再把Board挂到服务端上。走子消息的JSON建议设计成{ fx: 3, fy: 2, tx: 5, ty: 2, seq: 1024 }。seq是客户端自增序号用来处理乱序和重发虽然TCP按序到达但丢包重连场景下seq能帮你判断是否已经应用过这条消息。每次本地走完要立即刷新界面不要等ACK再刷新局域网内差异不大以后走公网体验更好的做法是“本地乐观更新失败回滚”先落子、后对账服务端仲裁不通过就回滚。这套机制配合单一房间限制已经足够支撑完整对局。注意服务端只做仲裁不做规则校验时消息结构里必须带seq否则重连后无法判断是否重复应用了走子。5. 网络对战避坑指南粘包、卡死、编译报错一次说清这一章写的每一条都是我自己在这个项目不同阶段实际踩过的坑按“现象→原因→解决”整理。这里的几个坑和标题里的骁将没有直接关系但是做Qt网络对战最容易让人卡住的地方建议收藏当排查手册用。5.1 粘包与半包收到一条消息就处理结果消息被切开了现象客户端先收到“将5进1”紧跟着又冒出一条毫无关系的“车3平2”或者消息内容只有一半就触发了处理逻辑直接解析失败。原因TCP不保存应用层的消息边界。一次send可能被网络栈拆成多个包发送多次send也可能合并成一个包到达。这在校园网、Wi-Fi环境下几乎每天都会发生不是小概率事件。解决不要对readAll返回的字节直接处理。先把数据追加到一个QByteArray缓冲里再调用decodeBuffer逐条拆包。拆包时如果长度不够就退出循环把剩余字节留在缓冲里等下一次readyRead。decodeBuffer里的while循环和buffer.remove是同一条消息边界的收敛点写测试时重点构造两条消息拼在一起发送的用例能过这一个测试粘包问题基本就治住了。5.2 点一下“连接”按钮界面就假死阻塞调用惹的祸现象点击连接服务器后窗口失去响应鼠标变漏斗几十秒后恢复并提示连接超时。原因在按钮槽函数里调用了socket-waitForConnected(3000)。这个时间内Qt事件循环被卡死paintEvent、鼠标消息全部排队窗口看起来就像卡死。网上大量旧示例喜欢这么写因为同步代码好理解但放到有GUI的程序里就是在自埋雷。解决全部改异步。connectToHost之后立刻返回真正的结果从connected和errorOccurred信号里获取。如果确实需要同步等待把它放到QtConcurrent::run或QThread里把结果信号回传GUI线程。局域网对局没有必须同步等待的场景信号槽路数写顺了比同步还省心。记住一条原则GUI线程里不允许出现任何超过几十毫秒的阻塞操作。5.3 启动即崩找不到qt platform plugin linuxfb或cannot mix incompatible qt library现象程序在Windows上编译运行正常拷到Linux开发板或无桌面环境的服务器上一启动就段错误终端报“qt.qpa.plugin: could not find the qt platform plugin linuxfb”。原因Qt的GUI需要平台插件对接具体显示系统。Windows默认windows平台插件Linux桌面用xcb嵌入式常用linuxfb。目标环境缺插件或者QT_QPA_PLATFORM环境变量指定错误程序在创建QApplication时就会崩。至于cannot mix incompatible qt library (version ex50601)是同一台机器上混用了不同Qt版本或不同编译器的库常见于下载了别人预编译的第三方组件。解决桌面Linux先检查libxcb依赖缺了就装或者在可执行文件目录放好plugins/platforms并设置QT_QPA_PLATFORM_PLUGIN_PATH指向它。跨平台发布前先用目标环境跑一个最小的QWidget空窗口确认环境没问题再查业务代码。混用库的问题把build目录整个删掉重新qmake编译再检查系统PATH里有没有多个Qt版本bin目录基本都能救回来。5.4 走棋没反应但也不报错连接断了你并不知道现象对局中突然怎么点都没反应界面不报错也没有退出提示就是走不动。原因TCP断连不是即时的。网线被拔、对手休眠、Wi-Fi切换socket可能既读不到数据也写不成功但错误不会立刻触发。如果只依赖errorOccurred可能要等系统超时才能发现掉线期间另一方只能干瞪眼。解决加心跳。客户端每3秒发一个type6心跳包服务端记录每个socket最后一次活跃时间超过9秒没收到就判定掉线并通知另一方“对局已中断”。心跳包负载带当前棋局序号顺带做数据一致性检查。这个机制看起来平淡但它是重连和悔棋功能的地基没有心跳后面所有进阶功能都无从谈起。5.5 编译期踩坑unknown module(s) in qt 与 cannot find -lpublic现象在.pro文件里写QT webenginewidgets编译直接报unknown module(s) in qt: webenginewidgets或者引入某个第三方库后报cannot find -lpublic。原因前者是安装Qt时没勾选WebEngine模块属于模块缺失后者是LIBS里写了-l对应库名但编译器找不到这个库或者库里没导出对应符号属于路径或符号问题。解决不需要WebEngine就从.pro里去掉改用QT widgets network这两个模块在5.15.2里必装不会缺失。真需要WebEngine回安装器补装模块重开项目。链接库报错时先用LIBS -L/path/to/lib -lmylib把路径写全再用nm或objdump确认目标库有对应符号。这类问题九成是环境不一致和代码逻辑无关排查优先级排在最前面。提示在Qt Creator里切Kit之后别直接点“运行”先执行“清除”再重新qmake能省掉一半莫名其妙的链接错误。6. 进阶技巧断线重连、悔棋与棋谱回放6.1 断线重连别让一盘棋毁在网线上基础版心跳只能感知掉线体验再进一步是断线重连。重连状态机我一般设三个状态Playing、Reconnecting、Resyncing。客户端检测掉线后进入Reconnecting用一个QTimer每1秒尝试一次connectToHost最多试10次连上后立即发送带roomId的同步请求服务端把当前棋盘序列化发给客户端客户端覆盖本地棋盘回到Playing。这里必须给Board加一个serialize方法把32个棋子的类型和位置打成JSON数组。没有序列化重连回来就是两个玩家各下各的局面完全对不上。服务端保留最近一局棋盘快照就行内存里存一个大对象不是负担重连逻辑全部写在一个状态机类里别散落在各个信号槽中。6.2 悔棋与棋谱回放一套历史栈的两种用法悔棋难在双方同步。正确流程是请求方点悔棋发送type4消息对方确认后双方同时从本地历史栈弹出最后一步并恢复被吃棋子。对方拒绝就什么都不做绝不能在确认前单独回滚否则时序一错后续棋谱全废。历史栈里每步存from、to、capturedId三个字段。棋谱回放用的是同一套历史栈。每次走子成功压栈回放时用QTimer每隔500毫秒出一条apply到棋盘上重绘再把坐标反推成“炮二平五”这样的文字。比录视频的好处是回放过程仍可做分支分析比如“第12步改走马3进4会怎样”以后接AI分析引擎也顺理成章。struct Step { int fx, fy, tx, ty, captured; }; QVectorStep history; void undoLast() { if (history.isEmpty()) return; Step s history.last(); board.setPiece(s.fx, s.fy, board.at(s.tx, s.ty)); board.setPiece(s.tx, s.ty, s.captured); history.removeLast(); boardView-update(); }我自己的习惯是把protocol.h和Board的规则测试放在一起每次改协议先跑粘包测试用例再进编译。这套项目做完后最大的收获不是QTcpSocket一开始用得多熟练而是意识到网络编程里“消息边界”和“状态同步”才是真正的硬骨头绘图反而只是体力活。换个棋种、换套牌类玩法骨架都能平移到别的项目只要网络协议设计不塌整个项目基本就不会翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表