简介:这份网络联机五子棋项目源码面向具备C++基础、希望掌握Qt界面开发与Socket网络通信的开发者,提供一套可在公网对局的客户端与服务端完整实现。客户端用Qt搭建菜单、棋盘和对局窗口,网络层基于Windows平台C++ Socket;服务端运行在Linux环境下,通过Linux Socket完成连接管理、消息转发与对局状态维护,便于对照理解两端网络编程的差异与协作方式。压缩包共28个文件、约559KB,以cpp、h、ui等核心代码与界面文件为主,配合png、jpg、ico图像资源以及qrc、pro、makefile等工程配置,整体代码量适中、双端结构分明,目录紧凑、依赖关系清晰,方便快速定位客户端界面、通信模块和服务端逻辑。已有1400人学习,适合用来梳理联机棋类游戏的连接建立、落子同步、断线处理与胜负判定流程,也可作为课程设计、毕业项目或二次开发的参考底稿。
1. 一个能联机的五子棋,为什么值得拿来当 C++ Qt 的练手项目
很多人学完 Qt 的信号槽、画过几个控件之后就卡住了:界面能拼,逻辑也能写,但一到“两个人隔着网络下同一盘棋”就不知道从哪下手。这份 C++ Qt 网络联机五子棋小游戏源码,解决的正是这个断档期的问题——它把游戏开发里最容易出岔子的三件事放在一起:棋盘绘制、对局规则、网络状态同步。项目本身不大,服务端和客户端都由 Qt 写成,本地双人和局域网联机两种模式都有,开房、轮流落子、五连判胜、悔棋请求、认输、断线处理这些流程都在代码里跑得通。适合已经能写点 C++、摸过 Qt 控件,但没做过完整联机项目的人;也适合想拿一个“能从头读到尾”的工程,去理解 Qt 网络模块到底怎么用的人。
2. 工程骨架与通信协议:先看清代码往哪儿放,再看数据怎么走
拿到这类源码,我的习惯是先不看 UI,直接翻目录和网络层。UI 部分改起来最快,也最没技术含量;真正决定这份代码能不能二次开发的,是工程怎么拆、包怎么定义。这个项目属于典型的“一个 qmake 工程、两个可执行程序”结构,服务端和客户端共享一部分规则代码,共用一套消息定义。
2.1 目录结构:server 与 client 分仓,规则层抽出来共用
理解结构比读代码重要。下面是我按项目实际组织方式整理出来的骨架,实际目录名可能略有差异,但分层逻辑基本一致:
gomoku/ ├── common/ # 双端共用,不放任何 UI 代码 │ ├── protocol.h # 消息 type 常量、字段名宏 │ ├── tcppacket.h/.cpp # 长度前缀 + JSON 的编解码器 │ └── gamerule.h/.cpp # 棋盘数据、落子合法性、五连判定 ├── server/ │ ├── main.cpp │ ├── gameserver.h/.cpp # QTcpServer 子类,负责 accept 与配对 │ ├── room.h/.cpp # 一局对战的会话对象,持有两名玩家 │ └── server.ui # 一个极简的日志窗口,方便调试 ├── client/ │ ├── main.cpp │ ├── boardwidget.h/.cpp # QWidget 子类,paintEvent 画棋盘和棋子 │ ├── gametable.h/.cpp # 对局窗口,管按钮、状态栏、socket │ └── connectdialog.h/.cpp# 填 IP 和端口、选先手 └── gomoku.pro # SUBDIRS 工程,一次编译出两个 exe这个分法最大的好处是common/里的东西可以被两边同时include,规则只有一份实现。我见过不少同学的写法是把判定逻辑分别抄在客户端和服务端,改个规则要改两处,改漏一处的后果就是两边算出来的胜负不一致——这在联机游戏里是灾难性的。
common/里的gamerule是纯数据类,不继承QObject,不依赖网络,也不依赖界面。这一点很关键:它能被单独拿出来做单元测试,也能被服务端用来做权威判定,还能被客户端用来做“预判落子”的乐观 UI。
2.2 协议设计:长度前缀 + JSON,别被 TCP 的“流”骗了
新手写 Qt 网络代码最容易翻车的地方,是把QTcpSocket当成消息队列用。TCP 是字节流,不是包流:你write()两次,对端可能一次readyRead()就把两份数据全读到了;你write()一个 2KB 的 JSON,对端也可能只先读到 800 字节。这就是粘包和半包,不处理的话表现就是“偶尔丢一步棋”“偶尔解析失败”,而且复现极不稳定 —— 玄学程度堪比抽卡。
项目里的做法是标准的长度前缀法:先写 4 字节的包体长度,再写 JSON 正文。发送端代码大致长这样:
// common/tcppacket.cpp void TcpPacket::send(QTcpSocket *sock, const QJsonObject &obj) { if (!sock || sock->state() != QAbstractSocket::ConnectedState) return; // 1. 序列化成紧凑 JSON,减少包体体积 const QByteArray body = QJsonDocument(obj).toJson(QJsonDocument::Compact); // 2. 4 字节大端长度头,双端必须用同一个 QDataStream 版本 QByteArray header; QDataStream out(&header, QIODevice::WriteOnly); out.setVersion(QDataStream::Qt_5_15); out << quint32(body.size()); // 3. 头和体一次性写出,避免中间被别的 write 插队 sock->write(header + body); }接收端是另一个方向,核心是一个累积缓冲区,循环拆包:
// common/tcppacket.cpp void TcpPacket::onReadyRead() { m_buffer.append(m_socket->readAll()); while (true) { if (m_buffer.size() < 4) // 连长度头都不够,等下次 return; quint32 bodyLen = 0; QDataStream in(m_buffer); in.setVersion(QDataStream::Qt_5_15); in >> bodyLen; if (bodyLen > 1024 * 1024) { // 防御:异常长度直接断开 m_socket->abort(); return; } if (m_buffer.size() < 4 + int(bodyLen)) // 半包,等下次 return; const QByteArray body = m_buffer.mid(4, bodyLen); m_buffer.remove(0, 4 + bodyLen); // 先取再删,顺序别反 QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(body, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) emit packetReceived(doc.object()); } }out.setVersion()这一行看着不起眼,但它是双端兼容的生命线。QDataStream在不同 Qt 版本下对quint32的编码其实是一致的,但对QString、QSize这类类型就未必了。项目里只传整数和字符串,仍然显式锁版本,是因为一旦你后面加字段时顺手写了个QDateTime,版本不一致就会解析出莫名其妙的值。我一般会在协议里再补一个"v": 1的协议版本号,客户端连上来先校验,版本对不上就提示升级,比后面抓瞎强。
顺带说一个长度字段的坑:quint32是 4 字节,qint32也是 4 字节,但如果你用QDataStream << int再在另一端用>> quint32,在数据正常时也没问题,出问题时就是负数长度导致的死循环。统一用一种类型,别混。
2.3 消息类型:一张表把双端约定说清
项目里的消息都用type字段区分,服务端收到后 switch 分发。整理出来大概是这样几类:
| type | 方向 | 关键字段 | 作用 |
|---|---|---|---|
login | C → S | name | 客户端报送昵称,进入等待队列 |
start | S → C | roomId、role(1 黑 2 白) | 配对成功,下发执子颜色 |
move | 双向 | x、y、role | 落子,服务端校验后广播 |
move_ack | S → C | ok、reason | 落子被拒时的回执,客户端回滚 UI |
undo_request | C → S | — | 请求悔棋 |
undo_reply | C → S | accept | 对方是否同意 |
undo_done | S → C | x、y | 服务端撤销成功后广播 |
give_up | C → S | — | 认输 |
game_over | S → C | winner、reason | 终局,弹结算 |
ping/pong | 双向 | — | 心跳保活 |
full_state | S → C | board、turn、history | 重连或中途接入时同步全量对局 |
有了这张表,前后端联调就变成“对表”而不是“对猜”。字段名建议直接用constexpr char或#define放在protocol.h里,别在两边各写一遍字面量 —— 拼错一个字母,编译器不报错,运行起来就是消息被静默丢弃,排查起来非常痛苦。
2.4 编译与双实例联调:一次跑通两个进程
.pro用的是SUBDIRS,根目录下三行就能生成两个目标:
# gomoku.pro TEMPLATE = subdirs SUBDIRS = server client server.file = server/server.pro client.file = client/client.pro编译时用 qmake 或 CMake 都能过,qmake && make -j8(Windows 下是qmake之后打开生成的.sln,或者直接nmake)。跑联调的时候有个小技巧:先启动服务端,它默认listen在12345端口,日志窗口打一行“listening”;再启动两个客户端实例,第一个填127.0.0.1,第二个也填127.0.0.1,服务端配对逻辑会把它们凑成一间房,一个执黑一个执白。如果只有一台机器,这就是最省事的验证方式;两台机器联调时,客户端里那个 IP 输入框填服务端的内网地址即可,端口保持一致。
这一步跑不通,先别急着改代码,去服务端日志看有没有 accept 记录。有记录说明网络通、卡在配对逻辑;没记录说明根本没连上,问题在地址、端口或防火墙,跟游戏代码没关系。
3. 棋盘与对局核心:QPainter 绘制、落子合法性与五连判定
网络层通了之后,剩下的是“这盘棋怎么画、怎么判”。这一块是纯 C++ 加 Qt 绘图,没有网络干扰,最适合拿来练手,也是二次开发时改动最频繁的部分。
3.1 棋盘模型:一个二维数组就够,别急着上对象
棋盘状态用QVector<QVector<int>>或者定长二维数组都行,15×15 一共 225 个格子,值 0 表示空、1 表示黑、2 表示白。用int而不是enum class是为了序列化方便,转 JSON 数组时直接就是数字。真正的封装放在GameRule里:
// common/gamerule.h class GameRule { public: static constexpr int Size = 15; GameRule() { reset(); } void reset() { m_board.assign(Size, QVector<int>(Size, 0)); m_history.clear(); m_turn = 1; // 1 黑先手 m_finished = false; } bool inBoard(int x, int y) const { return x >= 0 && x < Size && y >= 0 && y < Size; } int at(int x, int y) const { return m_board[y][x]; } int turn() const { return m_turn; } bool finished() const { return m_finished; } const QVector<QPoint> &history() const { return m_history; } // 落子:合法才写盘,返回是否成功 bool place(int x, int y, int role) { if (m_finished || role != m_turn) return false; if (!inBoard(x, y) || m_board[y][x] != 0) return false; m_board[y][x] = role; m_history.append(QPoint(x, y)); m_turn = (role == 1 ? 2 : 1); return true; } // 悔棋:撤销最后一手,把行动权还给它 bool undoLast() { if (m_history.isEmpty() || m_finished) return false; const QPoint p = m_history.takeLast(); const int role = m_board[p.y()][p.x()]; m_board[p.y()][p.x()] = 0; m_turn = role; return true; } private: QVector<QVector<int>> m_board; QVector<QPoint> m_history; int m_turn = 1; bool m_finished = false; };m_history这个数组比看起来有用得多:悔棋靠它,回放靠它,断线重连时给客户端补状态也靠它。新手常犯的错误是只存棋盘不存历史,等到要做“上一步高亮”或者“复盘”时才发现无从下手,只能推倒重来。多存一份QVector<QPoint>内存开销可以忽略,收益却是实打实的。
3.2 paintEvent 里的坐标换算:先把原点算对,再谈好看
棋盘绘制的难点不在画线,而在坐标换算要统一。鼠标点击得到的是像素坐标,棋盘存的是格子索引,两者之间的映射必须只有一处实现,否则缩放窗口后就会出现“点这儿下那儿”的错位。
// client/boardwidget.cpp void BoardWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); p.fillRect(rect(), QColor(0xE8, 0xC0, 0x80)); // 木色底 // 1. 根据控件大小算出格距与左上角原点,保证棋盘始终居中 const int pad = 20; const int side = qMin(width(), height()) - pad * 2; m_step = side / (GameRule::Size - 1); m_origin = QPoint((width() - m_step * (GameRule::Size - 1)) / 2, (height() - m_step * (GameRule::Size - 1)) / 2); // 2. 网格 p.setPen(QPen(QColor(0x40, 0x30, 0x20), 1)); for (int i = 0; i < GameRule::Size; ++i) { const int d = i * m_step; p.drawLine(m_origin + QPoint(0, d), m_origin + QPoint(m_step * (GameRule::Size - 1), d)); p.drawLine(m_origin + QPoint(d, 0), m_origin + QPoint(d, m_step * (GameRule::Size - 1))); } // 3. 天元和星位,纯装饰但能明显提升“像个棋盘”的观感 p.setBrush(QColor(0x40, 0x30, 0x20)); for (int gx : {3, 7, 11}) for (int gy : {3, 7, 11}) p.drawEllipse(m_origin + QPoint(gx * m_step, gy * m_step), 3, 3); // 4. 棋子 for (int y = 0; y < GameRule::Size; ++y) for (int x = 0; x < GameRule::Size; ++x) { const int v = m_rule.at(x, y); if (v == 0) continue; const QPoint c = m_origin + QPoint(x * m_step, y * m_step); p.setPen(Qt::NoPen); p.setBrush(v == 1 ? QColor(0x20, 0x20, 0x20) : QColor(0xF5, 0xF5, 0xF5)); p.drawEllipse(c, int(m_step * 0.42), int(m_step * 0.42)); } // 5. 最后一手标记,联机时对手落子你能一眼看到 if (!m_rule.history().isEmpty()) { const QPoint last = m_rule.history().last(); const QPoint c = m_origin + QPoint(last.x() * m_step, last.y() * m_step); p.setPen(QPen(Qt::red, 2)); p.setBrush(Qt::NoBrush); p.drawEllipse(c, int(m_step * 0.42), int(m_step * 0.42)); } }m_origin和m_step算完之后要存成成员变量,因为mousePressEvent里还要用同一套换算把像素反推成索引:
void BoardWidget::mousePressEvent(QMouseEvent *e) { if (m_rule.finished() || m_myRole != m_rule.turn()) return; // 不是你的回合,点了也没用 const QPoint rel = e->pos() - m_origin; const int x = qRound(qreal(rel.x()) / m_step); const int y = qRound(qreal(rel.y()) / m_step); if (!m_rule.inBoard(x, y) || m_rule.at(x, y) != 0) return; emit moveRequested(x, y); // 交给上层发网络包 }用qRound而不是整除,是因为玩家点击时不可能精确点在交叉点上,四舍五入到最近的交叉点才符合手感。这里还有一个隐性细节:m_step为 0 时(控件刚创建还没 resize)会除零,稳妥的做法是加一句if (m_step <= 0) return;。
3.3 五连判定:只检查最后一手,别每次全盘扫
判定胜负不需要遍历整个棋盘。既然只有刚落下的那颗子可能形成新的五连,那就以它为中心,向四个方向各走一遍即可:
// common/gamerule.cpp bool GameRule::checkWin(int x, int y, int role) const { if (!inBoard(x, y) || m_board[y][x] != role) return false; // 四个方向:横、竖、主对角、副对角 static const int dx[4] = {1, 0, 1, 1}; static const int dy[4] = {0, 1, 1, -1}; for (int d = 0; d < 4; ++d) { int count = 1; // s = -1 走负方向,s = 1 走正方向,两个方向的结果相加 for (int s = -1; s <= 1; s += 2) { int nx = x + dx[d] * s; int ny = y + dy[d] * s; while (inBoard(nx, ny) && m_board[ny][nx] == role) { ++count; nx += dx[d] * s; ny += dy[d] * s; } } if (count >= 5) return true; // 用 >= 兼容长连(六连、七连都算赢) } return false; }参数说明:x、y是刚落子的坐标,role是落子方。用>= 5而不是== 5,是因为不同规则集对“长连”的处理不一样——标准五子棋里长连也算胜,而有些变体规则(比如某些禁手规则)会把长连判负。把判定写成函数,规则要改的时候只改这一处。
这里也有个容易忽略的边界:如果服务端在广播move之前就自己调了place(),客户端收到广播后又调一次place()会失败,因为该点已经有子了。正确的顺序是:客户端收到move广播后只更新棋盘和 UI,不重复校验;place()的合法性检查只在发起方本地做“乐观预判”时用一次。
3.4 悔棋与状态机:为什么“轮到谁”必须由服务端说了算
悔棋看着简单,其实是整个项目里最容易出并发问题的地方。假设两端各自维护m_turn,玩家 A 点了悔棋、B 点同意,如果这时 B 那边刚好也点了一下棋盘,两边的m_turn就可能永久错位,表现是“轮到我了但点不动”或者“对方连下两步”。
这个项目的做法是把m_turn和m_history的权威版本全部放在服务端的Room对象里,客户端只做展示。悔棋流程是三步:
// server/room.cpp void Room::handleUndoRequest(QTcpSocket *from) { // 1. 空盘或已结束不能悔 if (m_rule.history().isEmpty() || m_rule.finished()) { sendError(from, "当前无法悔棋"); return; } // 2. 请求方必须是刚下完那一手的人,否则等于替对手悔棋 if (from == m_current) { sendError(from, "请等对方落子后再申请"); return; } // 3. 转发给对方裁决,己方先挂起,不做本地撤销 m_pendingUndoFrom = from; QJsonObject req{{"type", "undo_ask"}}; TcpPacket::send(other(from), req); } void Room::handleUndoReply(QTcpSocket *from, bool accept) { if (!m_pendingUndoFrom) return; if (!accept) { QJsonObject rej{{"type", "undo_result"}, {"ok", false}}; TcpPacket::send(m_pendingUndoFrom, rej); m_pendingUndoFrom = nullptr; return; } m_rule.undoLast(); // 服务端真正撤销 QJsonObject ok{{"type", "undo_result"}, {"ok", true}}; TcpPacket::send(m_pendingUndoFrom, ok); broadcastState(); // 双方按服务端状态重绘 m_pendingUndoFrom = nullptr; }关键点在于“客户端不在本地撤销”。如果客户端收到同意就先自己把棋子抹掉,一旦服务端因为状态不一致而撤销失败,两边显示就会分叉。稳妥的做法永远是:本地操作先发请求,等服务端回执再改 UI;实在要追求手感,就先做视觉上的“半透明预览”,回执到了再落定。这条经验在后面做任何联机功能时都通用。
4. 联机链路:QTcpServer 接入、QTcpSocket 收包与房间管理
网络层是一个QTcpServer加一堆QTcpSocket的问题,但真正写好要处理的是三件事:连接生命周期、房间配对、异常断开。这一章拆开讲。
4.1 服务端:一个 QTcpServer,一个等待队列,若干房间
服务端不需要多线程。五子棋这种回合制游戏,单连接每秒的消息量几乎为零,单线程事件循环完全扛得住。GameServer继承QObject,内部持有一个QTcpServer:
// server/gameserver.cpp void GameServer::start(quint16 port) { connect(&m_server, &QTcpServer::newConnection, this, &GameServer::onNewConnection); if (!m_server.listen(QHostAddress::AnyIPv4, port)) { qWarning() << "listen failed:" << m_server.errorString(); return; } qInfo() << "listening on" << m_server.serverPort(); } void GameServer::onNewConnection() { while (m_server.hasPendingConnections()) { QTcpSocket *sock = m_server.nextPendingConnection(); // 关闭 Nagle,回合制小包延迟能明显降低 sock->setSocketOption(QAbstractSocket::LowDelayOption, 1); auto *codec = new TcpPacket(sock, this); connect(codec, &TcpPacket::packetReceived, this, &GameServer::onPacket); connect(sock, &QTcpSocket::disconnected, this, &GameServer::onDisconnected); m_clients.insert(sock, ClientInfo{codec, QString()}); m_waiting.append(sock); // 先进等待队列 tryPair(); // 凑够两人就开房 } }配对逻辑本身很短,但有个细节值得说:先手由服务端决定,不要让两个客户端各自随机。两边同时随机,就有 50% 的概率撞成同色,然后一整局都在互相覆盖棋子,你还以为是网络问题。
void GameServer::tryPair() { while (m_waiting.size() >= 2) { QTcpSocket *a = m_waiting.takeFirst(); QTcpSocket *b = m_waiting.takeFirst(); auto *room = new Room(a, b, m_nextRoomId++, this); m_rooms.insert(room->id(), room); // 服务端统一分配:a 执黑先手,b 执白 room->sendStart(a, 1); room->sendStart(b, 2); qInfo() << "room" << room->id() << "created"; } }Room里保存两个 socket、一个GameRule实例、当前行动方指针。所有业务消息都先经过Room::handleMessage(),由它判断“这条消息是谁发的、现在轮不轮得到他、合不合法”,然后再决定转发还是拒绝。这个“单一裁判”的结构,是后面所有功能能稳定扩展的前提。
4.2 客户端:连接、发包、断线提示三件事
客户端侧的核心是一个GameTable窗口,它持有一个QTcpSocket和一个TcpPacket。连接成功的信号里做 UI 状态切换:
// client/gametable.cpp void GameTable::connectToServer(const QString &host, quint16 port, const QString &name) { m_socket->connectToHost(host, port); if (!m_socket->waitForConnected(3000)) { // 仅用于连接对话框,别放主循环 QMessageBox::warning(this, "连接失败", m_socket->errorString()); return; } m_board->setEnabled(false); // 等 start 消息到了再放开 QJsonObject login{{"type", "login"}, {"name", name}}; TcpPacket::send(m_socket, login); }waitForConnected是阻塞调用,只适合在“点连接按钮”这种用户明确等待的场景用,千万别在readyRead回调里调它,否则会把事件循环卡死。真正运行期间的所有收发都走信号槽,是非阻塞的。
收到start之后再做三件事:记录自己的颜色m_myRole、清空本地棋盘、把状态栏切成“等待对方落子”。收到move就调用m_rule.place()更新模型并update()重绘;收到move_ack且ok == false时,如果本地已经乐观落子,必须回滚——这就是前面说的“乐观 UI 要配回滚”。
4.3 心跳与超时:对面拔网线了你得知道
TCP 有个特性:对端进程被强杀、网线被拔,本端可能几分钟都收不到disconnected信号,因为协议栈还在等超时重传。对局中这会表现为“对方一直不落子”,你以为是他在思考,其实人早走了。
项目里的处理是在服务端起一个QTimer,3 秒一轮:
// server/gameserver.cpp void GameServer::onHeartbeat() { const auto sockets = m_clients.keys(); for (QTcpSocket *s : sockets) { ClientInfo &info = m_clients[s]; if (info.awaitPong) { // 上一轮 ping 还没回 qInfo() << "heartbeat timeout, kick" << s->peerAddress().toString(); s->disconnectFromHost(); continue; } info.awaitPong = true; TcpPacket::send(s, QJsonObject{{"type", "ping"}}); } }客户端收到ping立刻回pong,收到pong就把awaitPong清掉。阈值不用太敏感,3 秒一轮、连续两轮不回再踢,实际用下来误判率很低,断线感知也能控制在 10 秒内。踢人之后别忘了通知房间里的另一位:Room收到disconnected时要广播一条opponent_left,让对面弹提示并结束对局,否则另一个客户端会一直傻等。
4.4 线程问题:QTcpSocket 不要跨线程用
这份源码是单线程的,但二次开发时很容易手痒——比如想加个 AI 对手,把搜索放在子线程里跑。这时候有个硬规矩:QTcpSocket有线程亲和性,在哪个线程创建就必须在哪个线程使用,跨线程直接调它的方法会出现难以复现的崩溃或警告。
正确姿势是把网络和计算分开:
// 反例:在工作线程里直接操作主线程创建的 socket,必炸 // QThread::create([sock]{ sock->write(data); })->start(); // 正例:计算放子线程,结果通过信号回主线程,由主线程发包 class AiWorker : public QObject { Q_OBJECT public slots: void think(QVector<QVector<int>> board, int role) { const QPoint best = searchBestMove(board, role); // 纯计算,不碰 socket emit decided(best); } signals: void decided(QPoint p); };connect时保持默认的Qt::AutoConnection,跨线程会自动变成队列连接,参数里的QPoint、QVector都是可拷贝类型,能安全跨线程传。想传自定义结构就注册qRegisterMetaType,不然运行时会打印“Cannot queue arguments of type”然后信号静默失效。
5. 联机五子棋避坑与排查:五条实打实的踩坑记录
这一章是我自己跑这类项目时踩过的、也在别人代码里反复见到的坑。按“现象 → 原因 → 解决”整理,遇到问题可以先对号入座。
坑一:本机能连,另一台机器连接超时。现象是服务端日志有 listening,客户端waitForConnected返回 false,errorString()是 Connection refused 或 Timeout。原因多半是m_server.listen(QHostAddress::LocalHost, port),这个地址只监听回环网卡,外部机器根本连不进来。改成QHostAddress::AnyIPv4就能解决;如果还不行,检查系统防火墙有没有放行该端口,以及两台机器是否在同一网段。这个坑我在第一次做联调时卡了整整一晚上,最后发现是绑定地址写错了,跟网络毫无关系。
坑二:偶尔丢一步棋,或者 JSON 解析失败。现象是对局中偶发“对方下了但我这边没显示”,日志里能看到QJsonParseError。原因是接收端假设一次readyRead就是一个完整包,直接把readAll()丢给QJsonDocument::fromJson。TCP 不保证消息边界,必须用累积缓冲加长度头拆包,也就是 2.2 节那段代码。解决之后再跑一晚上压力测试都不会丢包。
坑三:双方同时落子,出现“双活三”这种鬼局。现象是同一回合里棋盘上多了两颗子,或者一方连下两手。原因是胜负和轮到谁都在客户端各自判断,网络一延迟两边的状态就分叉了。解决办法是把GameRule的权威实例只放在服务端的Room里,客户端发move请求,服务端校验通过再广播,客户端收到广播才落子。这条改完,状态不一致类的问题基本绝迹。
坑四:断线重连后棋盘空白或错位。现象是玩家网络抖了一下,重连回来发现自己这边棋子少了几颗,而对手那边是完整的。原因是重连只重建了 socket,没有补对局状态。解决是服务端在Room里保留m_rule和m_history,客户端重新login并被识别为原玩家后,服务端下发一条full_state,里面带上整个棋盘数组、当前行动方和历史落子序列,客户端整体覆盖本地模型再重绘。别试图靠“重放所有历史消息”来恢复,中间只要丢一条就永远对不上。
坑五:程序退出时报 QObject 跨线程警告或直接崩溃。现象是关窗口时控制台刷QObject::killTimer: Timers cannot be stopped from another thread,或者析构时 crash。原因是 socket、timer 这些对象挂在父对象上,而父对象先于它们被销毁,或者信号还连着就已经 delete 了。稳妥写法是断开连接时先disconnect(sock, nullptr, this, nullptr),再sock->deleteLater(),不要直接delete。自定义的TcpPacket也用deleteLater,它内部可能还有未处理完的readyRead事件在队列里。
6. 二次开发:把规则抽成可插拔,先加回放再加观战
这份代码跑通之后,最值得做的改造不是换皮肤,而是把“规则”和“流程”解耦。当前的GameRule把棋盘、落子、胜负判定揉在一个类里,标准五子棋够用,但想加禁手、想改成四子棋、想加 AI 就有点别扭。我的做法是先抽一个极简接口:
// common/irule.h class IRule { public: virtual ~IRule() = default; virtual bool place(int x, int y, int role) = 0; virtual bool undoLast() = 0; virtual bool checkWin(int x, int y, int role) const = 0; virtual QJsonObject snapshot() const = 0; // 给 full_state 用 virtual void restore(const QJsonObject &s) = 0; };标准五子棋实现GomokuRule,禁手版本实现RenjuRule,服务端建房时按配置选一个塞进Room。这样加规则不用动网络层一行代码,客户端也不用改,因为它只认move和full_state两种消息。抽接口的时候注意别把snapshot()设计成返回裸数组,用QJsonObject带版本号,后面加字段才不会破坏老客户端。
回放功能是这套结构最直接的收益。因为m_history里已经存了完整的落子序列,回放无非是按时间轴重放:
// client/replaywidget.cpp void ReplayWidget::loadReplay(const QJsonArray &moves) { m_moves = moves; m_cursor = 0; m_rule.reset(); m_timer.start(600); // 每 600ms 走一步,节奏刚好 } void ReplayWidget::onTick() { if (m_cursor >= m_moves.size()) { m_timer.stop(); return; } const QJsonObject mv = m_moves.at(m_cursor++).toObject(); m_rule.place(mv["x"].toInt(), mv["y"].toInt(), mv["role"].toInt()); update(); // 复用 BoardWidget 的绘制逻辑 }观战则是把Room从“两个 socket”扩成“两个玩家 socket + 一组观察者 socket”,落子广播时遍历观察者列表一起发。这里有个坑要提前想清楚:观战者不应该收到undo_ask这种只有对局双方才需要处理的消息,广播时要按角色过滤,别图省事一把全发,否则观战端会弹出“是否同意悔棋”的对话框,非常尴尬。
验证改造有没有破坏原有逻辑,我一般会写一个不依赖 UI 的小测试:构造GomokuRule,按固定序列落子,断言checkWin在第 9 手返回 true、在第 8 手返回 false;再测一次悔棋后turn()是否回到正确颜色。这个测试跑起来不到一秒,但能挡住绝大多数“改了一处崩了三处”的回归。
最后说个习惯。从那以后我每次动联机项目的协议字段,都会先把双端的type常量和字段名对着protocol.h走一遍,再跑一次“服务端 + 两个客户端”的三进程联调,确认开房、落子、悔棋、断线四条链路都通,才敢提交。联机项目的问题从来不是写不出来,而是改完之后不知道哪条路悄悄断了。希望帮到你。
本文还有配套的精品资源,点击获取